001/*
002 * Portions of this software was developed by employees of the National Institute
003 * of Standards and Technology (NIST), an agency of the Federal Government and is
004 * being made available as a public service. Pursuant to title 17 United States
005 * Code Section 105, works of NIST employees are not subject to copyright
006 * protection in the United States. This software may be subject to foreign
007 * copyright. Permission in the United States and in foreign countries, to the
008 * extent that NIST may hold copyright, to use, copy, modify, create derivative
009 * works, and distribute this software and its documentation without fee is hereby
010 * granted on a non-exclusive basis, provided that this notice and disclaimer
011 * of warranty appears in all copies.
012 *
013 * THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND, EITHER
014 * EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY
015 * THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF
016 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND FREEDOM FROM
017 * INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION WILL CONFORM TO THE
018 * SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE ERROR FREE.  IN NO EVENT
019 * SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO, DIRECT,
020 * INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES, ARISING OUT OF, RESULTING FROM,
021 * OR IN ANY WAY CONNECTED WITH THIS SOFTWARE, WHETHER OR NOT BASED UPON WARRANTY,
022 * CONTRACT, TORT, OR OTHERWISE, WHETHER OR NOT INJURY WAS SUSTAINED BY PERSONS OR
023 * PROPERTY OR OTHERWISE, AND WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT
024 * OF THE RESULTS OF, OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER.
025 */
026
027package gov.nist.secauto.metaschema.core.datatype.markup.flexmark; // NOPMD AST processor has many members
028
029import com.vladsch.flexmark.ast.AutoLink;
030import com.vladsch.flexmark.ast.BlockQuote;
031import com.vladsch.flexmark.ast.Code;
032import com.vladsch.flexmark.ast.CodeBlock;
033import com.vladsch.flexmark.ast.FencedCodeBlock;
034import com.vladsch.flexmark.ast.HardLineBreak;
035import com.vladsch.flexmark.ast.Heading;
036import com.vladsch.flexmark.ast.HtmlBlock;
037import com.vladsch.flexmark.ast.HtmlCommentBlock;
038import com.vladsch.flexmark.ast.HtmlEntity;
039import com.vladsch.flexmark.ast.HtmlInline;
040import com.vladsch.flexmark.ast.Image;
041import com.vladsch.flexmark.ast.IndentedCodeBlock;
042import com.vladsch.flexmark.ast.Link;
043import com.vladsch.flexmark.ast.ListBlock;
044import com.vladsch.flexmark.ast.ListItem;
045import com.vladsch.flexmark.ast.MailLink;
046import com.vladsch.flexmark.ast.OrderedList;
047import com.vladsch.flexmark.ast.Paragraph;
048import com.vladsch.flexmark.ast.ParagraphItemContainer;
049import com.vladsch.flexmark.ast.Text;
050import com.vladsch.flexmark.ast.TextBase;
051import com.vladsch.flexmark.ast.ThematicBreak;
052import com.vladsch.flexmark.ext.escaped.character.EscapedCharacter;
053import com.vladsch.flexmark.ext.tables.TableBlock;
054import com.vladsch.flexmark.ext.tables.TableBody;
055import com.vladsch.flexmark.ext.tables.TableCell;
056import com.vladsch.flexmark.ext.tables.TableHead;
057import com.vladsch.flexmark.ext.tables.TableRow;
058import com.vladsch.flexmark.ext.typographic.TypographicQuotes;
059import com.vladsch.flexmark.ext.typographic.TypographicSmarts;
060import com.vladsch.flexmark.parser.ListOptions;
061import com.vladsch.flexmark.util.ast.Block;
062import com.vladsch.flexmark.util.ast.Node;
063import com.vladsch.flexmark.util.sequence.BasedSequence;
064import com.vladsch.flexmark.util.sequence.Escaping;
065
066import gov.nist.secauto.metaschema.core.datatype.markup.flexmark.HtmlQuoteTagExtension.DoubleQuoteNode;
067import gov.nist.secauto.metaschema.core.datatype.markup.flexmark.InsertAnchorExtension.InsertAnchorNode;
068import gov.nist.secauto.metaschema.core.util.CollectionUtil;
069import gov.nist.secauto.metaschema.core.util.ObjectUtils;
070
071import org.apache.commons.text.StringEscapeUtils;
072import org.jsoup.Jsoup;
073import org.jsoup.nodes.Attributes;
074import org.jsoup.nodes.Document;
075import org.jsoup.select.NodeVisitor;
076
077import java.net.URI;
078import java.net.URISyntaxException;
079import java.util.HashMap;
080import java.util.LinkedHashMap;
081import java.util.Map;
082import java.util.regex.Matcher;
083import java.util.regex.Pattern;
084
085import javax.xml.namespace.QName;
086
087import edu.umd.cs.findbugs.annotations.NonNull;
088import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
089
090/**
091 * Used to write HTML-based Markup to various types of streams.
092 *
093 * @param <T>
094 *          the type of stream to write to
095 * @param <E>
096 *          the type of exception that can be thrown when a writing error occurs
097 */
098@SuppressFBWarnings(
099    value = "THROWS_METHOD_THROWS_CLAUSE_THROWABLE",
100    justification = "Class supports writers that use both Exception and RuntimeException.")
101public abstract class AbstractMarkupWriter<T, E extends Throwable> // NOPMD not god class
102    implements IMarkupWriter<T, E> {
103  private static final Pattern ENTITY_PATTERN = Pattern.compile("^&([^;]+);$");
104  private static final Map<String, String> ENTITY_MAP;
105
106  static {
107    ENTITY_MAP = new HashMap<>();
108    // special cases
109    ENTITY_MAP.put("&npsb;", "&npsb;");
110    // ENTITY_MAP.put("&gt;", ">");
111    // normal cases
112    // ENTITY_MAP.put("&amp;", "&");
113    /*
114     * ENTITY_MAP.put("&lsquo;", "‘"); ENTITY_MAP.put("&rsquo;", "’"); ENTITY_MAP.put("&hellip;", "…");
115     * ENTITY_MAP.put("&mdash;", "—"); ENTITY_MAP.put("&ndash;", "–"); ENTITY_MAP.put("&ldquo;", "“");
116     * ENTITY_MAP.put("&rdquo;", "”"); ENTITY_MAP.put("&laquo;", "«"); ENTITY_MAP.put("&raquo;", "»");
117     */
118  }
119
120  @NonNull
121  private final String namespace;
122
123  @NonNull
124  private final T stream;
125
126  @NonNull
127  private final ListOptions options;
128
129  public AbstractMarkupWriter(@NonNull String namespace, @NonNull ListOptions options, T stream) {
130    this.namespace = namespace;
131    this.options = options;
132    this.stream = ObjectUtils.requireNonNull(stream);
133  }
134
135  @NonNull
136  protected String getNamespace() {
137    return namespace;
138  }
139
140  protected ListOptions getOptions() {
141    return options;
142  }
143
144  @NonNull
145  protected T getStream() {
146    return stream;
147  }
148
149  @Override
150  @NonNull
151  public QName asQName(@NonNull String localName) {
152    return new QName(getNamespace(), localName);
153  }
154
155  protected void visitChildren(
156      @NonNull Node parentNode,
157      @NonNull ChildHandler<T, E> childHandler) throws E {
158    for (Node node : parentNode.getChildren()) {
159      assert node != null;
160      childHandler.accept(node, this);
161    }
162  }
163
164  protected void writePrecedingNewline(@NonNull Block node) throws E {
165    Node prev = node.getPrevious();
166    if (prev != null
167        || !(node.getParent() instanceof com.vladsch.flexmark.util.ast.Document)) {
168      writeText("\n");
169    }
170  }
171
172  protected void writeTrailingNewline(@NonNull Block node) throws E {
173    Node next = node.getNext();
174    if (next != null && !next.isOrDescendantOfType(Block.class) // handled by preceding block
175        || next == null && !(node.getParent() instanceof com.vladsch.flexmark.util.ast.Document)) {
176      writeText("\n");
177    }
178  }
179
180  @Override
181  public final void writeElement(
182      QName qname,
183      Node node,
184      Map<String, String> attributes,
185      ChildHandler<T, E> childHandler) throws E {
186    if (node.hasChildren()) {
187      writeElementStart(qname, attributes);
188      if (childHandler != null) {
189        visitChildren(node, childHandler);
190      }
191      writeElementEnd(qname);
192    } else {
193      writeEmptyElement(qname, attributes);
194    }
195  }
196
197  @SuppressWarnings({
198      "unchecked",
199      "unused",
200      "PMD.UnusedPrivateMethod"
201  }) // while unused, keeping code for when inline HTML is supported
202  private void writeHtml(Node node) throws E {
203    Document doc = Jsoup.parse(node.getChars().toString());
204    try {
205      doc.body().traverse(new MarkupNodeVisitor());
206    } catch (NodeVisitorException ex) {
207      throw (E) ex.getCause(); // NOPMD exception is wrapper
208    }
209  }
210
211  @Override
212  public final void writeText(Text node) throws E {
213    BasedSequence text = node.getChars();
214    Node prev = node.getPrevious();
215    if (prev instanceof HardLineBreak) {
216      // strip leading after hard line break
217      assert text != null;
218      text = text.trimStart();
219    }
220    assert text != null;
221    writeText(text);
222  }
223
224  @Override
225  public void writeText(@NonNull TextBase node) throws E {
226    StringBuilder buf = new StringBuilder(node.getChars().length());
227    for (Node child : node.getChildren()) {
228      CharSequence chars;
229      if (child instanceof Text) {
230        Text text = (Text) child;
231        chars = text.getChars();
232      } else if (child instanceof EscapedCharacter) {
233        EscapedCharacter ec = (EscapedCharacter) child;
234        chars = ec.getChars().unescape();
235      } else {
236        throw new UnsupportedOperationException("Node type: " + child.getNodeName());
237      }
238      buf.append(chars);
239    }
240    writeText(buf);
241  }
242
243  @Override
244  public void writeHtmlEntity(@NonNull HtmlEntity node) throws E {
245    String text = node.getChars().unescape();
246    assert text != null;
247    writeHtmlEntity(text);
248  }
249
250  @Override
251  public void writeHtmlEntity(@NonNull TypographicSmarts node) throws E {
252    String text = ObjectUtils.requireNonNull(node.getTypographicText());
253    assert text != null;
254    writeHtmlEntity(text);
255  }
256
257  private void writeHtmlEntity(String entityText) throws E {
258    String replacement = ENTITY_MAP.get(entityText);
259    if (replacement != null) {
260      Matcher matcher = ENTITY_PATTERN.matcher(replacement);
261      if (matcher.matches()) {
262        writeHtmlEntityInternal(ObjectUtils.notNull(matcher.group(1)));
263      } else {
264        writeText(replacement);
265      }
266    } else {
267      String value = StringEscapeUtils.unescapeHtml4(entityText);
268      assert value != null;
269      writeText(value);
270    }
271  }
272
273  protected void writeHtmlEntityInternal(@NonNull String text) throws E {
274    writeText(text);
275  }
276
277  @Override
278  public void writeParagraph(
279      @NonNull Paragraph node,
280      @NonNull ChildHandler<T, E> childHandler) throws E {
281    if (node.getParent() instanceof ParagraphItemContainer && getOptions().isInTightListItem(node)) {
282      if (node.getPrevious() != null) {
283        writeText("\n");
284      }
285      visitChildren(node, childHandler);
286    } else {
287      writePrecedingNewline(node);
288      writeElement("p", node, childHandler);
289      writeTrailingNewline(node);
290    }
291  }
292
293  @Override
294  public void writeLink(
295      @NonNull Link node,
296      @NonNull ChildHandler<T, E> childHandler) throws E {
297    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
298    String href = Escaping.percentEncodeUrl(node.getUrl().unescape());
299    try {
300      attributes.put("href", new URI(href).toASCIIString());
301    } catch (URISyntaxException ex) {
302      throw new IllegalStateException(ex);
303    }
304
305    if (!node.getTitle().isBlank()) {
306      String title = ObjectUtils.requireNonNull(node.getTitle().unescape());
307      attributes.put("title", title);
308    }
309
310    // writeElement("a", node, attributes, childHandler);
311    QName qname = asQName("a");
312    writeElementStart(qname, attributes);
313    if (node.hasChildren()) {
314      visitChildren(node, childHandler);
315    } else {
316      writeText("");
317    }
318    writeElementEnd(qname);
319  }
320
321  @Override
322  public void writeLink(@NonNull MailLink node) throws E {
323    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
324
325    String href = Escaping.percentEncodeUrl(node.getText().unescape());
326    try {
327      attributes.put("href", new URI("mailto:" + href).toASCIIString());
328    } catch (URISyntaxException ex) {
329      throw new IllegalStateException(ex);
330    }
331
332    QName qname = asQName("a");
333    writeElementStart(qname, attributes);
334
335    BasedSequence text = node.getText();
336    writeText(text == null ? "\n" : ObjectUtils.notNull(text.unescape()));
337    writeElementEnd(qname);
338  }
339
340  @Override
341  public void writeLink(@NonNull AutoLink node) throws E {
342    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
343
344    String href = Escaping.percentEncodeUrl(node.getUrl().unescape());
345    try {
346      attributes.put("href", new URI(href).toASCIIString());
347    } catch (URISyntaxException ex) {
348      throw new IllegalStateException(ex);
349    }
350
351    QName qname = asQName("a");
352    writeElementStart(qname, attributes);
353    writeText(ObjectUtils.notNull(node.getText().unescape()));
354    writeElementEnd(qname);
355  }
356
357  @Override
358  public final void writeTypographicQuotes(
359      TypographicQuotes node,
360      ChildHandler<T, E> childHandler) throws E {
361    if (node instanceof DoubleQuoteNode) {
362      writeElement("q", node, childHandler);
363    } else {
364      String opening = node.getTypographicOpening();
365      if (opening != null && !opening.isEmpty()) {
366        writeHtmlEntity(opening);
367      }
368
369      visitChildren(node, childHandler);
370
371      String closing = node.getTypographicClosing();
372      if (closing != null && !closing.isEmpty()) {
373        writeHtmlEntity(closing);
374      }
375    }
376  }
377
378  @Override
379  public final void writeInlineHtml(HtmlInline node) throws E {
380    // throw new UnsupportedOperationException(
381    // String.format("Inline HTML is not supported. Found: %s", node.getChars()));
382    writeHtml(node);
383  }
384
385  @Override
386  public final void writeBlockHtml(HtmlBlock node) throws E {
387    // throw new UnsupportedOperationException(
388    // String.format("Inline HTML is not supported. Found: %s", node.getChars()));
389
390    writePrecedingNewline(node);
391    writeHtml(node);
392    writeTrailingNewline(node);
393  }
394
395  @Override
396  public final void writeTable(
397      TableBlock node,
398      ChildHandler<T, E> cellChildHandler) throws E {
399    writePrecedingNewline(node);
400    QName qname = asQName("table");
401    writeElementStart(qname);
402
403    TableHead head = (TableHead) node.getChildOfType(TableHead.class);
404
405    QName theadQName = asQName("thead");
406    if (head != null) {
407      writeText("\n");
408      writeElementStart(theadQName);
409      for (Node childNode : head.getChildren()) {
410        if (childNode instanceof TableRow) {
411          writeTableRow((TableRow) childNode, cellChildHandler);
412        }
413      }
414      writeElementEnd(theadQName);
415    }
416
417    TableBody body = (TableBody) node.getChildOfType(TableBody.class);
418
419    if (body != null) {
420      QName tbodyQName = asQName("tbody");
421      writeText("\n");
422      writeElementStart(tbodyQName);
423      for (Node childNode : body.getChildren()) {
424        if (childNode instanceof TableRow) {
425          writeTableRow((TableRow) childNode, cellChildHandler);
426        }
427      }
428      writeElementEnd(tbodyQName);
429    }
430
431    writeText("\n");
432    writeElementEnd(qname);
433    writeTrailingNewline(node);
434  }
435
436  private void writeTableRow(
437      @NonNull TableRow node,
438      @NonNull ChildHandler<T, E> cellChildHandler) throws E {
439    writeText("\n");
440    QName qname = asQName("tr");
441    writeElementStart(qname);
442
443    for (Node childNode : node.getChildren()) {
444      if (childNode instanceof TableCell) {
445        writeTableCell((TableCell) childNode, cellChildHandler);
446      }
447    }
448
449    writeElementEnd(qname);
450    if (node.getNext() == null) {
451      writeText("\n");
452    }
453  }
454
455  private void writeTableCell(
456      @NonNull TableCell node,
457      @NonNull ChildHandler<T, E> cellChildHandler) throws E {
458    QName qname = node.isHeader() ? asQName("th") : asQName("td");
459
460    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
461    if (node.getAlignment() != null) {
462      attributes.put("align", ObjectUtils.requireNonNull(node.getAlignment().toString()));
463    }
464
465    writeElementStart(qname, attributes);
466    visitChildren(node, cellChildHandler);
467    writeElementEnd(qname);
468  }
469
470  @Override
471  public void writeImage(
472      @NonNull Image node) throws E {
473    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
474    String href = ObjectUtils.requireNonNull(Escaping.percentEncodeUrl(node.getUrl().unescape()));
475    try {
476      attributes.put("src", new URI(href).toASCIIString());
477    } catch (URISyntaxException ex) {
478      throw new IllegalStateException(ex);
479    }
480
481    attributes.put("alt", ObjectUtils.requireNonNull(node.getText().toString()));
482
483    if (!node.getTitle().isBlank()) {
484      attributes.put("title", ObjectUtils.requireNonNull(node.getTitle().toString()));
485    }
486
487    writeEmptyElement("img", attributes);
488  }
489
490  @Override
491  public void writeInsertAnchor(@NonNull InsertAnchorNode node) throws E {
492    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
493    attributes.put("type", ObjectUtils.requireNonNull(node.getType().toString()));
494    attributes.put("id-ref", ObjectUtils.requireNonNull(node.getIdReference().toString()));
495
496    writeElement("insert", node, attributes, null);
497  }
498
499  @Override
500  public void writeHeading(
501      @NonNull Heading node,
502      @NonNull ChildHandler<T, E> childHandler) throws E {
503    writePrecedingNewline(node);
504    int level = node.getLevel();
505
506    QName qname = asQName(ObjectUtils.notNull(String.format("h%d", level)));
507
508    writeElementStart(qname);
509    if (node.hasChildren()) {
510      visitChildren(node, childHandler);
511    } else {
512      // ensure empty tags are created
513      writeText("");
514    }
515    writeElementEnd(qname);
516    writeTrailingNewline(node);
517  }
518
519  /**
520   * Normalize whitespace according to
521   * https://spec.commonmark.org/0.30/#code-spans. Based on code from Flexmark.
522   *
523   * @param text
524   *          text to process
525   * @return the normalized text
526   */
527  @NonNull
528  protected static String collapseWhitespace(@NonNull CharSequence text) {
529    StringBuilder sb = new StringBuilder(text.length());
530    int length = text.length();
531    boolean needsSpace = false;
532    for (int i = 0; i < length; i++) {
533      char ch = text.charAt(i);
534      // convert line endings to spaces
535      if (ch == '\n' || ch == '\r') {
536        if (sb.length() > 0) {
537          // ignore leading
538          needsSpace = true;
539        }
540      } else {
541        if (needsSpace) {
542          sb.append(' ');
543          needsSpace = false;
544        }
545        sb.append(ch);
546      }
547    }
548
549    String result = sb.toString();
550    if (result.matches("^[ ]{1,}[^ ].* $")) {
551      // if there is a space at the start and end, remove them
552      result = result.substring(1, result.length() - 1);
553    }
554    return ObjectUtils.notNull(result);
555  }
556
557  @Override
558  public void writeCode(Code node, ChildHandler<T, E> childHandler) throws E {
559    QName qname = asQName("code");
560    writeElementStart(qname);
561    visitChildren(node, (child, writer) -> {
562      if (child instanceof Text || child instanceof TextBase) {
563        String text = collapseWhitespace(ObjectUtils.notNull(child.getChars()));
564        writeText(text);
565      } else {
566        childHandler.accept(child, writer);
567      }
568    });
569    writeElementEnd(qname);
570  }
571
572  @Override
573  public final void writeCodeBlock(
574      IndentedCodeBlock node,
575      ChildHandler<T, E> childHandler) throws E {
576    writePrecedingNewline(node);
577    QName preQName = asQName("pre");
578
579    writeElementStart(preQName);
580
581    QName codeQName = asQName("code");
582
583    writeElementStart(codeQName);
584
585    if (node.hasChildren()) {
586      visitChildren(node, childHandler);
587    } else {
588      // ensure empty tags are created
589      writeText("");
590    }
591
592    writeElementEnd(codeQName);
593
594    writeElementEnd(preQName);
595    writeTrailingNewline(node);
596  }
597
598  @Override
599  public final void writeCodeBlock(
600      FencedCodeBlock node,
601      ChildHandler<T, E> childHandler) throws E {
602    writePrecedingNewline(node);
603    QName preQName = asQName("pre");
604
605    writeElementStart(preQName);
606
607    QName codeQName = asQName("code");
608    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
609    if (node.getInfo().isNotNull()) {
610      attributes.put("class", "language-" + node.getInfo().unescape());
611    }
612
613    writeElementStart(codeQName, attributes);
614
615    if (node.hasChildren()) {
616      visitChildren(node, childHandler);
617    } else {
618      // ensure empty tags are created
619      writeText("");
620    }
621
622    writeElementEnd(codeQName);
623
624    writeElementEnd(preQName);
625    writeTrailingNewline(node);
626  }
627
628  @Override
629  public void writeCodeBlock(CodeBlock node, ChildHandler<T, E> childHandler) throws E {
630    String text;
631    if (node.getParent() instanceof IndentedCodeBlock) {
632      text = node.getContentChars().trimTailBlankLines().toString();
633    } else {
634      text = node.getContentChars().toString();
635    }
636    writeText(ObjectUtils.notNull(text));
637  }
638
639  @Override
640  public void writeBlockQuote(BlockQuote node, ChildHandler<T, E> childHandler) throws E {
641    writePrecedingNewline(node);
642    QName qname = asQName("blockquote");
643    writeElementStart(qname);
644    // writeText("\n");
645
646    if (node.hasChildren()) {
647      visitChildren(node, childHandler);
648    } else {
649      // ensure empty tags are created
650      writeText("\n");
651    }
652
653    // writeText("\n");
654    writeElementEnd(qname);
655    writeTrailingNewline(node);
656  }
657
658  @Override
659  public void writeList(QName qname, ListBlock node, ChildHandler<T, E> listItemHandler) throws E {
660    Map<String, String> attributes = new LinkedHashMap<>(); // NOPMD local use; thread-safe
661    if (node instanceof OrderedList) {
662      OrderedList ol = (OrderedList) node;
663      int start = ol.getStartNumber();
664      if (start != 1) {
665        attributes.put("start", String.valueOf(start));
666      }
667    }
668
669    writePrecedingNewline(node);
670    writeElementStart(qname, attributes);
671
672    visitChildren(node, (child, writer) -> {
673      ListItem item = (ListItem) child;
674      writeListItem(item, listItemHandler);
675    });
676
677    writeElementEnd(qname);
678    writeTrailingNewline(node);
679  }
680
681  @Override
682  public void writeListItem(ListItem node, ChildHandler<T, E> listItemHandler) throws E {
683    QName qname = asQName("li");
684    writePrecedingNewline(node);
685    writeElementStart(qname);
686
687    if (node.hasChildren()) {
688      visitChildren(node, listItemHandler);
689    } else {
690      // ensure empty tags are created
691      writeText("");
692    }
693    writeElementEnd(qname);
694    writeTrailingNewline(node);
695  }
696
697  @Override
698  public void writeBreak(HardLineBreak node) throws E {
699    writeElement("br", node, null);
700    writeText("\n");
701  }
702
703  @Override
704  public void writeBreak(ThematicBreak node) throws E {
705    writePrecedingNewline(node);
706    writeElement("hr", node, null);
707    writeTrailingNewline(node);
708  }
709
710  @Override
711  public void writeComment(HtmlCommentBlock node) throws E {
712    writePrecedingNewline(node);
713
714    BasedSequence text = node.getChars();
715    text = text.subSequence(4, text.length() - 4);
716    writeComment(ObjectUtils.notNull(text.unescape()));
717    writeTrailingNewline(node);
718
719  }
720
721  protected abstract void writeComment(@NonNull CharSequence text) throws E;
722
723  protected static class NodeVisitorException
724      extends IllegalStateException {
725    /**
726     * the serial version uid.
727     */
728    private static final long serialVersionUID = 1L;
729
730    public NodeVisitorException(Throwable cause) {
731      super(cause);
732    }
733  }
734
735  private final class MarkupNodeVisitor implements NodeVisitor {
736    @Override
737    public void head(org.jsoup.nodes.Node node, int depth) { // NOPMD acceptable
738      if (depth > 0) {
739        try {
740          if (node instanceof org.jsoup.nodes.Element) {
741            org.jsoup.nodes.Element element = (org.jsoup.nodes.Element) node;
742
743            Attributes attributes = element.attributes();
744
745            Map<String, String> attrMap;
746            if (attributes.isEmpty()) {
747              attrMap = CollectionUtil.emptyMap();
748            } else {
749              attrMap = new LinkedHashMap<>();
750              for (org.jsoup.nodes.Attribute attr : attributes) {
751                attrMap.put(attr.getKey(), attr.getValue());
752              }
753            }
754
755            QName qname = asQName(ObjectUtils.notNull(element.tagName()));
756            if (element.childNodes().isEmpty()) {
757              writeEmptyElement(qname, attrMap);
758            } else {
759              writeElementStart(qname, attrMap);
760            }
761          } else if (node instanceof org.jsoup.nodes.TextNode) {
762            org.jsoup.nodes.TextNode text = (org.jsoup.nodes.TextNode) node;
763            writeText(ObjectUtils.requireNonNull(text.text()));
764          }
765        } catch (Throwable ex) { // NOPMD need to catch Throwable
766          throw new NodeVisitorException(ex);
767        }
768      }
769    }
770
771    @Override
772    public void tail(org.jsoup.nodes.Node node, int depth) {
773      if (depth > 0 && node instanceof org.jsoup.nodes.Element) {
774        org.jsoup.nodes.Element element = (org.jsoup.nodes.Element) node;
775        if (!element.childNodes().isEmpty()) {
776          QName qname = asQName(ObjectUtils.notNull(element.tagName()));
777          try {
778            writeElementEnd(qname);
779          } catch (Throwable ex) { // NOPMD need to catch Throwable
780            throw new NodeVisitorException(ex);
781          }
782        }
783      }
784    }
785  }
786}