001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH & Co. KG, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.util;
029
030import java.util.ArrayList;
031import java.util.HashMap;
032import java.util.List;
033import java.util.Map;
034import java.util.Set;
035
036import org.jsoup.Jsoup;
037import org.jsoup.nodes.Document;
038import org.jsoup.nodes.Element;
039import org.jsoup.nodes.Node;
040import org.jsoup.nodes.TextNode;
041
042/**
043 * Converts an HTML fragment to Markdown.<p>
044 *
045 * This converter is intended for HTML <i>pieces</i> (rich text fragments), not complete HTML pages.
046 * It parses the input with Jsoup using {@link Jsoup#parseBodyFragment(String)} and renders a common
047 * set of Markdown constructs: headings, paragraphs, bold and italic, inline code, links, images,
048 * ordered and unordered (including nested) lists, blockquotes, fenced code blocks and horizontal rules.<p>
049 *
050 * Tags that are not recognized are passed through, so their textual content is preserved. The main
051 * use case is to feed CMS content into LLM prompts in a compact, model friendly form.<p>
052 *
053 * Note: only a minimal set of Markdown special characters is escaped in text nodes. Perfect round
054 * trip escaping (for example of a leading "#", "-" or "&gt;" at the start of a line) is out of scope.
055 * GFM tables, task lists and strikethrough are not supported.<p>
056 *
057 * @since 22.0.0
058 */
059public class CmsHtml2MarkdownConverter {
060
061    /** The block level tags that are rendered as separate Markdown blocks. */
062    private static final Set<String> BLOCK_TAGS = Set.of(
063        "h1",
064        "h2",
065        "h3",
066        "h4",
067        "h5",
068        "h6",
069        "p",
070        "div",
071        "section",
072        "article",
073        "header",
074        "footer",
075        "figure",
076        "figcaption",
077        "blockquote",
078        "ul",
079        "ol",
080        "pre",
081        "hr",
082        "table");
083
084    /** The Markdown special characters that are escaped in text nodes. */
085    private static final Set<Character> ESCAPE_CHARS = Set.of(
086        Character.valueOf('\\'),
087        Character.valueOf('`'),
088        Character.valueOf('*'),
089        Character.valueOf('_'),
090        Character.valueOf('['),
091        Character.valueOf(']'));
092
093    /** Punctuation that attaches to the preceding word, so no separating space is inserted before it. */
094    private static final String ATTACHING_PUNCTUATION = ".,:;!?)]}";
095
096    /** Maps typographic symbols and punctuation to their plain ASCII replacement (keyed by code point). */
097    private static final Map<Character, String> SYMBOL_FOLD = createSymbolFold();
098
099    /** Whether images and their related markup are dropped from the output. */
100    private final boolean m_ignoreImages;
101
102    /** Whether links are rendered as their text only, dropping the link markup. */
103    private final boolean m_removeLinks;
104
105    /**
106     * Creates a new converter that keeps images and links untouched.<p>
107     */
108    public CmsHtml2MarkdownConverter() {
109
110        this(false, false);
111    }
112
113    /**
114     * Creates a new converter.<p>
115     *
116     * @param ignoreImages if <code>true</code>, images and their related markup are dropped from the output
117     * @param removeLinks if <code>true</code>, links are rendered as their text only, dropping the link markup
118     */
119    public CmsHtml2MarkdownConverter(boolean ignoreImages, boolean removeLinks) {
120
121        m_ignoreImages = ignoreImages;
122        m_removeLinks = removeLinks;
123    }
124
125    /**
126     * Converts the given HTML fragment to Markdown.<p>
127     *
128     * @param html the HTML fragment to convert
129     *
130     * @return the Markdown representation of the given HTML fragment
131     */
132    public static String html2markdown(String html) {
133
134        return html2markdown(html, false, false);
135    }
136
137    /**
138     * Converts the given HTML fragment to Markdown.<p>
139     *
140     * @param html the HTML fragment to convert
141     * @param ignoreImages if <code>true</code>, images and their related markup are dropped from the output
142     * @param removeLinks if <code>true</code>, links are rendered as their text only, dropping the link markup
143     *
144     * @return the Markdown representation of the given HTML fragment
145     */
146    public static String html2markdown(String html, boolean ignoreImages, boolean removeLinks) {
147
148        return new CmsHtml2MarkdownConverter(ignoreImages, removeLinks).convert(html);
149    }
150
151    /**
152     * Builds the symbol-to-ASCII fold map following the NLM Lexical Tools mapSymbolToAscii table.<p>
153     *
154     * Invisible characters (soft hyphen, zero width spaces, word joiner, byte order mark) are not folded
155     * here; they are removed earlier in <code>{@link #normalizeWhitespace(String)}</code>.<p>
156     *
157     * @return the symbol-to-ASCII fold map
158     */
159    private static Map<Character, String> createSymbolFold() {
160
161        Map<Character, String> map = new HashMap<Character, String>(128);
162        putFold(map, "-", 0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2212);
163        putFold(map, "--", 0x2015);
164        putFold(
165            map,
166            Character.toString('"'),
167            0x00AB,
168            0x00BB,
169            0x02BA,
170            0x030B,
171            0x030E,
172            0x201C,
173            0x201D,
174            0x201E,
175            0x201F,
176            0x2033,
177            0x2036,
178            0x3003,
179            0x301D,
180            0x301E);
181        putFold(map, "'", 0x00B4, 0x02B9, 0x02BC, 0x02C8, 0x0301, 0x2018, 0x2019, 0x201B, 0x2032);
182        putFold(map, "'''", 0x2034, 0x2037);
183        putFold(map, "`", 0x02CB, 0x0300, 0x2035);
184        putFold(map, "_", 0x02CD, 0x0331, 0x0332, 0x2017);
185        putFold(map, "~", 0x02DC, 0x0303, 0x2053, 0x223C, 0x301C);
186        putFold(map, "^", 0x02C4, 0x02C6, 0x0302, 0x2038, 0x2303);
187        putFold(map, "/", 0x00F7, 0x0338, 0x2044, 0x2215);
188        putFold(map, Character.toString('\\'), 0x20E5, 0x2216);
189        putFold(map, "|", 0x01C0, 0x05C0, 0x2223, 0x2758);
190        putFold(map, "||", 0x2016);
191        putFold(map, "!", 0x01C3, 0x2762);
192        putFold(map, ":", 0x0589, 0x05C3, 0x2236);
193        putFold(map, "%", 0x066A, 0x2052);
194        putFold(map, "*", 0x066D, 0x204E, 0x2217, 0x2731);
195        putFold(map, "<", 0x2039, 0x2329, 0x27E8, 0x3008);
196        putFold(map, ">", 0x203A, 0x232A, 0x27E9, 0x3009);
197        putFold(map, "<=", 0x2264, 0x2266);
198        putFold(map, ">=", 0x2265, 0x2267);
199        putFold(map, "[", 0x27E6);
200        putFold(map, "]", 0x301B);
201        putFold(map, "{", 0x2983);
202        putFold(map, "}", 0x2984);
203        putFold(map, "#", 0x266F);
204        putFold(map, "?", 0x203D);
205        putFold(map, ",", 0x201A);
206        putFold(map, "...", 0x2026);
207        return map;
208    }
209
210    /**
211     * Detects the code language from a "language-xxx" CSS class, if present.<p>
212     *
213     * @param element the element to inspect
214     *
215     * @return the detected language, or the empty String if none was found
216     */
217    private static String detectLanguage(Element element) {
218
219        for (String name : element.classNames()) {
220            if (name.startsWith("language-")) {
221                return name.substring("language-".length());
222            }
223        }
224        return "";
225    }
226
227    /**
228     * Returns whether the given buffer ends with a whitespace character.<p>
229     *
230     * @param buffer the buffer to check
231     *
232     * @return <code>true</code> if the buffer ends with whitespace
233     */
234    private static boolean endsWithWhitespace(StringBuilder buffer) {
235
236        return (buffer.length() > 0) && Character.isWhitespace(buffer.charAt(buffer.length() - 1));
237    }
238
239    /**
240     * Backslash escapes a minimal set of Markdown special characters in the given text.<p>
241     *
242     * @param text the text to escape
243     *
244     * @return the escaped text
245     */
246    private static String escape(String text) {
247
248        StringBuilder result = new StringBuilder(text.length());
249        for (int i = 0; i < text.length(); i++) {
250            char c = text.charAt(i);
251            if (ESCAPE_CHARS.contains(Character.valueOf(c))) {
252                result.append('\\');
253            }
254            result.append(c);
255        }
256        return result.toString();
257    }
258
259    /**
260     * Appends the given inline buffer as a trimmed block to the given list, then resets the buffer.<p>
261     *
262     * @param inline the inline buffer
263     * @param blocks the list of blocks to append to
264     */
265    private static void flushInline(StringBuilder inline, List<String> blocks) {
266
267        String text = inline.toString().trim();
268        if (!text.isEmpty()) {
269            blocks.add(text);
270        }
271        inline.setLength(0);
272    }
273
274    /**
275     * Folds well known typographic symbols and punctuation to plain ASCII, following the NLM Lexical
276     * Tools symbol-to-ASCII mapping table (mapSymbolToAscii).<p>
277     *
278     * Dashes and the minus sign become a hyphen; typographic, angle and prime quotes become straight
279     * quotes or apostrophes; the ellipsis becomes three dots; and fancy slashes, bars, brackets,
280     * asterisks, carets, tildes, accents and comparison signs become their ASCII counterparts. Where a
281     * replacement is itself a Markdown special character it is escaped afterwards by
282     * <code>{@link #escape(String)}</code>.<p>
283     *
284     * @param text the text to fold
285     *
286     * @return the text with typographic symbols replaced by ASCII equivalents
287     */
288    private static String foldPunctuation(String text) {
289
290        StringBuilder result = new StringBuilder(text.length());
291        for (int i = 0; i < text.length(); i++) {
292            char c = text.charAt(i);
293            String replacement = SYMBOL_FOLD.get(Character.valueOf(c));
294            if (replacement != null) {
295                result.append(replacement);
296            } else {
297                result.append(c);
298            }
299        }
300        return result.toString();
301    }
302
303    /**
304     * Returns whether the given node must be rendered as a separate Markdown block.<p>
305     *
306     * A node is a block when its tag is a block level tag, or when it is an otherwise inline element
307     * (for example a link wrapping a whole card) that contains block level content; in the latter
308     * case it is unwrapped and rendered as blocks, so the wrapped headings and paragraphs keep their
309     * structure instead of being flattened to inline text.<p>
310     *
311     * @param node the node to check
312     *
313     * @return <code>true</code> if the node must be rendered as a block
314     */
315    private static boolean isBlock(Node node) {
316
317        if (!(node instanceof Element)) {
318            return false;
319        }
320        Element element = (Element)node;
321        if (BLOCK_TAGS.contains(element.tagName())) {
322            return true;
323        }
324        for (Node child : element.childNodes()) {
325            if (isBlock(child)) {
326                return true;
327            }
328        }
329        return false;
330    }
331
332    /**
333     * Returns whether the given element is an ordered or unordered list.<p>
334     *
335     * @param element the element to check
336     *
337     * @return <code>true</code> if the element is a ul or ol
338     */
339    private static boolean isList(Element element) {
340
341        return "ul".equals(element.tagName()) || "ol".equals(element.tagName());
342    }
343
344    /**
345     * Returns whether the given node is a structural or empty element whose boundary with adjacent
346     * content should be separated by a space. Text nodes and inline markup elements (bold, italic,
347     * inline code, links, line breaks, images and dropped script / style) are not separating, so they
348     * keep the whitespace as it is in the source.<p>
349     *
350     * @param node the node to check
351     *
352     * @return <code>true</code> if the node's boundary should get a separating space
353     */
354    private static boolean isSeparating(Node node) {
355
356        if (!(node instanceof Element)) {
357            return false;
358        }
359        switch (((Element)node).tagName()) {
360            case "a":
361            case "b":
362            case "br":
363            case "code":
364            case "em":
365            case "i":
366            case "img":
367            case "script":
368            case "strong":
369            case "style":
370                return false;
371            default:
372                return true;
373        }
374    }
375
376    /**
377     * Normalizes the whitespace in the given text for Markdown output.<p>
378     *
379     * Zero width and invisible formatting characters (soft hyphen, zero width spaces, word joiner,
380     * byte order mark) are dropped; runs of whitespace, including non breaking and other Unicode
381     * spaces, are collapsed to a single regular space (a single leading and trailing space is kept).<p>
382     *
383     * @param text the text to normalize
384     *
385     * @return the normalized text
386     */
387    private static String normalizeWhitespace(String text) {
388
389        // drop zero width and invisible formatting characters (soft hyphen, ZWSP, ZWNJ, ZWJ, word joiner, BOM)
390        String cleaned = text.replaceAll("[\\u00AD\\u200B\\u200C\\u200D\\u2060\\uFEFF]", "");
391        // collapse whitespace, including non breaking and other Unicode spaces, to a single regular space
392        return cleaned.replaceAll("[\\s\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]+", " ");
393    }
394
395    /**
396     * Puts a fold mapping for each of the given code points into the given map.<p>
397     *
398     * @param map the map to fill
399     * @param replacement the ASCII replacement string
400     * @param codePoints the source code points that map to the replacement
401     */
402    private static void putFold(Map<Character, String> map, String replacement, int... codePoints) {
403
404        for (int codePoint : codePoints) {
405            map.put(Character.valueOf((char)codePoint), replacement);
406        }
407    }
408
409    /**
410     * Renders an inline code element.<p>
411     *
412     * @param element the code element
413     *
414     * @return the rendered inline code, or the empty String if there is no content
415     */
416    private static String renderInlineCode(Element element) {
417
418        String code = element.text();
419        if (CmsStringUtil.isEmptyOrWhitespaceOnly(code)) {
420            return "";
421        }
422        return "`" + code + "`";
423    }
424
425    /**
426     * Renders a pre element as a fenced Markdown code block.<p>
427     *
428     * @param element the pre element
429     *
430     * @return the rendered fenced code block
431     */
432    private static String renderPre(Element element) {
433
434        Element codeElement = element.selectFirst("code");
435        String language = detectLanguage(codeElement != null ? codeElement : element);
436        String code = (codeElement != null ? codeElement : element).wholeText();
437        // strip a trailing line break to avoid an empty line before the closing fence
438        code = code.replaceAll("\\n+$", "");
439        return "```" + language + "\n" + code + "\n```";
440    }
441
442    /**
443     * Returns whether the given text starts with a punctuation character that attaches to the
444     * preceding word (see {@link #ATTACHING_PUNCTUATION}), so no separating space should precede it.<p>
445     *
446     * @param text the text to check
447     *
448     * @return <code>true</code> if the text starts with attaching punctuation
449     */
450    private static boolean startsWithPunctuation(String text) {
451
452        return !text.isEmpty() && (ATTACHING_PUNCTUATION.indexOf(text.charAt(0)) >= 0);
453    }
454
455    /**
456     * Returns whether the given text starts with a whitespace character.<p>
457     *
458     * @param text the text to check
459     *
460     * @return <code>true</code> if the text starts with whitespace
461     */
462    private static boolean startsWithWhitespace(String text) {
463
464        return !text.isEmpty() && Character.isWhitespace(text.charAt(0));
465    }
466
467    /**
468     * Wraps the given inline content with a Markdown marker, keeping leading and trailing
469     * whitespace outside the marker so the markup renders correctly.<p>
470     *
471     * @param content the inline content
472     * @param marker the marker to wrap with (for example "**")
473     *
474     * @return the wrapped content
475     */
476    private static String wrap(String content, String marker) {
477
478        String core = content.trim();
479        if (core.isEmpty()) {
480            return content;
481        }
482        int start = content.indexOf(core);
483        String leading = content.substring(0, start);
484        String trailing = content.substring(start + core.length());
485        return leading + marker + core + marker + trailing;
486    }
487
488    /**
489     * Converts the given HTML fragment to Markdown.<p>
490     *
491     * @param html the HTML fragment to convert
492     *
493     * @return the Markdown representation of the given HTML fragment
494     */
495    public String convert(String html) {
496
497        if (CmsStringUtil.isEmptyOrWhitespaceOnly(html)) {
498            return "";
499        }
500        Document doc = Jsoup.parseBodyFragment(html);
501        StringBuilder result = new StringBuilder();
502        for (String block : renderBlocks(doc.body())) {
503            // strip each block and drop blank ones, so a stray empty or trailing-newline block can
504            // never produce more than a single blank line between blocks (code block content, which
505            // starts and ends with a fence, is unaffected by the strip)
506            String trimmed = block.strip();
507            if (!trimmed.isEmpty()) {
508                if (result.length() > 0) {
509                    result.append("\n\n");
510                }
511                result.append(trimmed);
512            }
513        }
514        return result.toString();
515    }
516
517    /**
518     * Appends the inline rendering of a child node to the buffer, inserting a single separating space
519     * at the boundary between two structural or empty elements when none is present, so content that
520     * relies on CSS for spacing (for example list teaser tiles) does not run together. Inline markup
521     * (bold, italic, inline code, links, line breaks, images) keeps the source whitespace and is never
522     * force separated.<p>
523     *
524     * @param result the inline buffer
525     * @param previous the previously appended child node, or <code>null</code>
526     * @param child the child node to append
527     */
528    private void appendInline(StringBuilder result, Node previous, Node child) {
529
530        String part = renderInline(child);
531        if ((result.length() > 0)
532            && (isSeparating(previous) || isSeparating(child))
533            && !endsWithWhitespace(result)
534            && !startsWithWhitespace(part)
535            && !startsWithPunctuation(part)) {
536            result.append(' ');
537        }
538        result.append(part);
539    }
540
541    /**
542     * Renders a single block level element as a list of Markdown blocks.<p>
543     *
544     * @param element the block level element to render
545     *
546     * @return the list of Markdown blocks
547     */
548    private List<String> renderBlock(Element element) {
549
550        String tag = element.tagName();
551        if ((tag.length() == 2) && (tag.charAt(0) == 'h') && (tag.charAt(1) >= '1') && (tag.charAt(1) <= '6')) {
552            int level = tag.charAt(1) - '0';
553            String text = renderInlineChildren(element).replace('\n', ' ').trim();
554            return List.of("#".repeat(level) + " " + text);
555        }
556        switch (tag) {
557            case "hr":
558                return List.of("---");
559            case "pre":
560                return List.of(renderPre(element));
561            case "blockquote":
562                return List.of(renderBlockquote(element));
563            case "ul":
564                return List.of(renderList(element, false, "").stripTrailing());
565            case "ol":
566                return List.of(renderList(element, true, "").stripTrailing());
567            default:
568                // p, div and other (pass through) block containers
569                return renderBlocks(element);
570        }
571    }
572
573    /**
574     * Renders a blockquote element, prefixing every produced line with the Markdown quote marker.<p>
575     *
576     * @param element the blockquote element
577     *
578     * @return the rendered blockquote block
579     */
580    private String renderBlockquote(Element element) {
581
582        String inner = String.join("\n\n", renderBlocks(element));
583        StringBuilder result = new StringBuilder();
584        String[] lines = inner.split("\n", -1);
585        for (int i = 0; i < lines.length; i++) {
586            if (i > 0) {
587                result.append('\n');
588            }
589            result.append(lines[i].isEmpty() ? ">" : "> " + lines[i]);
590        }
591        return result.toString();
592    }
593
594    /**
595     * Renders the child nodes of the given element as a list of Markdown blocks.<p>
596     *
597     * Inline content is collected into paragraph blocks, block level children are rendered separately.<p>
598     *
599     * @param element the element whose children to render
600     *
601     * @return the list of Markdown blocks
602     */
603    private List<String> renderBlocks(Element element) {
604
605        List<String> blocks = new ArrayList<String>();
606        StringBuilder inline = new StringBuilder();
607        Node previous = null;
608        for (Node node : element.childNodes()) {
609            if (isBlock(node)) {
610                flushInline(inline, blocks);
611                blocks.addAll(renderBlock((Element)node));
612                previous = null;
613            } else {
614                appendInline(inline, previous, node);
615                previous = node;
616            }
617        }
618        flushInline(inline, blocks);
619        return blocks;
620    }
621
622    /**
623     * Renders an image element as a Markdown image.<p>
624     *
625     * @param element the image element
626     *
627     * @return the rendered Markdown image, or the empty String if there is no src
628     */
629    private String renderImage(Element element) {
630
631        if (m_ignoreImages) {
632            return "";
633        }
634        String src = element.attr("src").trim();
635        if (src.isEmpty()) {
636            return "";
637        }
638        String alt = element.attr("alt").trim();
639        String title = element.attr("title").trim();
640        String titlePart = title.isEmpty() ? "" : " \"" + title + "\"";
641        return "![" + alt + "](" + src + titlePart + ")";
642    }
643
644    /**
645     * Renders a single node as inline Markdown.<p>
646     *
647     * @param node the node to render
648     *
649     * @return the rendered inline Markdown
650     */
651    private String renderInline(Node node) {
652
653        if (node instanceof TextNode) {
654            return escape(foldPunctuation(normalizeWhitespace(((TextNode)node).getWholeText())));
655        }
656        if (!(node instanceof Element)) {
657            return "";
658        }
659        Element element = (Element)node;
660        switch (element.tagName()) {
661            case "strong":
662            case "b":
663                return wrap(renderInlineChildren(element), "**");
664            case "em":
665            case "i":
666                return wrap(renderInlineChildren(element), "*");
667            case "code":
668                return renderInlineCode(element);
669            case "a":
670                return renderLink(element);
671            case "img":
672                return renderImage(element);
673            case "br":
674                return "\n";
675            case "script":
676            case "style":
677                // non-content markup (for example ld+json structured data carrying an image
678                // copyright notice); drop the element and its text content entirely
679                return "";
680            default:
681                // span and any other inline wrapper: render the children only
682                return renderInlineChildren(element);
683        }
684    }
685
686    /**
687     * Renders all child nodes of the given element as inline Markdown.<p>
688     *
689     * @param element the element whose children to render
690     *
691     * @return the rendered inline Markdown
692     */
693    private String renderInlineChildren(Element element) {
694
695        StringBuilder result = new StringBuilder();
696        Node previous = null;
697        for (Node node : element.childNodes()) {
698            appendInline(result, previous, node);
699            previous = node;
700        }
701        return result.toString();
702    }
703
704    /**
705     * Renders an anchor element as a Markdown link.<p>
706     *
707     * @param element the anchor element
708     *
709     * @return the rendered Markdown link, or just the inline text if there is no href
710     */
711    private String renderLink(Element element) {
712
713        String text = renderInlineChildren(element);
714        if (m_removeLinks) {
715            return text;
716        }
717        String href = element.attr("href").trim();
718        if (href.isEmpty()) {
719            return text;
720        }
721        String core = text.trim();
722        if (core.isEmpty()) {
723            core = href;
724        }
725        String title = element.attr("title").trim();
726        String titlePart = title.isEmpty() ? "" : " \"" + title + "\"";
727        return "[" + core + "](" + href + titlePart + ")";
728    }
729
730    /**
731     * Renders an ordered or unordered list, including nested lists.<p>
732     *
733     * @param list the list element (ul or ol)
734     * @param ordered if the list is ordered
735     * @param indent the indentation prefix for the items of this list
736     *
737     * @return the rendered list, terminated by a line break
738     */
739    private String renderList(Element list, boolean ordered, String indent) {
740
741        StringBuilder result = new StringBuilder();
742        int counter = 1;
743        for (Element item : list.children()) {
744            if (!"li".equals(item.tagName())) {
745                continue;
746            }
747            String marker = ordered ? (counter++) + ". " : "- ";
748            StringBuilder inline = new StringBuilder();
749            List<Element> nested = new ArrayList<Element>();
750            Node previous = null;
751            for (Node node : item.childNodes()) {
752                if ((node instanceof Element) && isList((Element)node)) {
753                    nested.add((Element)node);
754                } else {
755                    appendInline(inline, previous, node);
756                    previous = node;
757                }
758            }
759            String text = inline.toString().replace('\n', ' ').trim();
760            result.append(indent).append(marker).append(text).append('\n');
761            String childIndent = indent + " ".repeat(marker.length());
762            for (Element nestedList : nested) {
763                result.append(renderList(nestedList, "ol".equals(nestedList.tagName()), childIndent));
764            }
765        }
766        return result.toString();
767    }
768}