From 5b4b1037866b0c6ed4b43419ae6667449d1a7af6 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:00:08 +0000 Subject: [PATCH] fix(markdown): preserve prose around nested inline lists --- .../service/markdown/InlineListParser.java | 55 ++++--- .../InlineListNestedTextDropReproTest.java | 147 ++++++++++++++++++ 2 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/service/markdown/InlineListNestedTextDropReproTest.java diff --git a/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java b/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java index 83b70b65..10560ea6 100644 --- a/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java +++ b/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java @@ -40,42 +40,47 @@ static Conversion tryConvert(String text) { return null; } - Element listElement = new Element(parse.primaryBlock().tagName()); - for (String entryLabel : parse.primaryBlock().entryLabels()) { - listElement.appendChild(new Element("li").text(entryLabel)); - } - List additionalLists = new ArrayList<>(); for (String nestedSegment : parse.nestedSegments()) { Parse nestedParse = Parse.tryParse(nestedSegment); if (nestedParse == null) continue; - Element nestedListElement = new Element(nestedParse.primaryBlock().tagName()); - for (String entryLabel : nestedParse.primaryBlock().entryLabels()) { - nestedListElement.appendChild(new Element("li").text(entryLabel)); - } - additionalLists.add(nestedListElement); - additionalLists.addAll(renderNestedListsRecursively(nestedParse, 1)); + additionalLists.addAll(renderNestedParse(nestedParse, 1)); } - return new Conversion(parse.leadingText(), listElement, additionalLists, parse.trailingText()); + return new Conversion(parse.leadingText(), buildListElement(parse), additionalLists, parse.trailingText()); } - private static List renderNestedListsRecursively(Parse parse, int depth) { - if (depth >= MAX_NESTED_DEPTH) { - return List.of(); + /** + * Renders a nested {@link Parse} as a flat sibling sequence that mirrors the top-level + * {@link Conversion} shape: leading prose, the list element, any deeper nested lists, then + * trailing prose. Nested leading/trailing prose is emitted as sibling {@code

} elements so + * prose surrounding a nested list is never silently dropped. + */ + private static List renderNestedParse(Parse parse, int depth) { + List renderedElements = new ArrayList<>(); + if (!parse.leadingText().isBlank()) { + renderedElements.add(new Element("p").text(parse.leadingText())); } - List listElements = new ArrayList<>(); - for (String nestedSegment : parse.nestedSegments()) { - Parse nestedParse = Parse.tryParse(nestedSegment); - if (nestedParse == null) continue; - Element nestedListElement = new Element(nestedParse.primaryBlock().tagName()); - for (String entryLabel : nestedParse.primaryBlock().entryLabels()) { - nestedListElement.appendChild(new Element("li").text(entryLabel)); + renderedElements.add(buildListElement(parse)); + if (depth < MAX_NESTED_DEPTH) { + for (String nestedSegment : parse.nestedSegments()) { + Parse nestedParse = Parse.tryParse(nestedSegment); + if (nestedParse == null) continue; + renderedElements.addAll(renderNestedParse(nestedParse, depth + 1)); } - listElements.add(nestedListElement); - listElements.addAll(renderNestedListsRecursively(nestedParse, depth + 1)); } - return listElements; + if (!parse.trailingText().isBlank()) { + renderedElements.add(new Element("p").text(parse.trailingText())); + } + return renderedElements; + } + + private static Element buildListElement(Parse parse) { + Element listElement = new Element(parse.primaryBlock().tagName()); + for (String entryLabel : parse.primaryBlock().entryLabels()) { + listElement.appendChild(new Element("li").text(entryLabel)); + } + return listElement; } /** diff --git a/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListNestedTextDropReproTest.java b/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListNestedTextDropReproTest.java new file mode 100644 index 00000000..c9a30d92 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListNestedTextDropReproTest.java @@ -0,0 +1,147 @@ +package com.williamcallahan.javachat.service.markdown; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.junit.jupiter.api.Test; + +/** + * End-to-end regression coverage for nested inline-list prose preservation. + * + *

Exercises the full {@link UnifiedMarkdownService#process(String)} pipeline (Flexmark → + * {@code renderInlineLists} → {@link InlineListParser#tryConvert(String)}), which previously dropped + * prose that sits between a colon and the first nested list marker, and/or after the last nested + * list marker, when an inline list item contained a colon-nested sub-list. + */ +class InlineListNestedTextDropReproTest { + + private final UnifiedMarkdownService service = new UnifiedMarkdownService(); + + @Test + void process_nestedLeadingProseBetweenColonAndMarker_isPreservedAsSiblingParagraph() { + String markdown = "Key points: 1. Setup phase: First gather tools a. Knife b. Spoon 2. Final cleanup"; + + String html = service.process(markdown).html(); + + Document document = parseFragment(html); + // The nested leading prose "First gather tools" must survive rendering as a

. + int proseIndex = indexOfParagraphContaining(document, "First gather tools"); + assertNotEquals(-1, proseIndex, "nested leading prose must be rendered, html=" + html); + // The nested list items also remain intact. + int nestedListIndex = indexOfFirstListContaining(document, "Knife"); + assertNotEquals(-1, nestedListIndex, "nested list must be rendered, html=" + html); + // Ordering: the leading prose paragraph precedes the nested list containing "Knife". + assertTrue(proseIndex < nestedListIndex, "nested leading prose must precede its nested list, html=" + html); + } + + @Test + void process_nestedTrailingProseAfterNestedList_isPreservedAsSiblingParagraph() { + String markdown = "Steps: 1. Setup: Prepare phase a. Step A b. Step B. Then finish 2. Done"; + + String html = service.process(markdown).html(); + + Document document = parseFragment(html); + // Both the nested leading prose and the nested trailing prose survive. + int leadingProseIndex = indexOfParagraphContaining(document, "Prepare phase"); + int trailingProseIndex = indexOfParagraphContaining(document, "Then finish"); + assertNotEquals(-1, leadingProseIndex, "nested leading prose must be rendered, html=" + html); + assertNotEquals(-1, trailingProseIndex, "nested trailing prose must be rendered, html=" + html); + int nestedListIndex = indexOfFirstListContaining(document, "Step A"); + assertNotEquals(-1, nestedListIndex, "nested list must be rendered, html=" + html); + // Ordering: leading prose → nested list → trailing prose. + assertTrue( + leadingProseIndex < nestedListIndex, "nested leading prose must precede its nested list, html=" + html); + assertTrue( + nestedListIndex < trailingProseIndex, + "nested trailing prose must follow its nested list, html=" + html); + } + + @Test + void process_nestedListWithoutExtraProse_introducesNoSpuriousParagraphs() { + // The originally-supported colon-abutting shape must keep rendering identically, with no + // spurious prose paragraphs injected alongside the nested list. + String markdown = "Notes: 1. Parent: a. Child one b. Child two 2. Another"; + + String html = service.process(markdown).html(); + + Document document = parseFragment(html); + Element nestedList = findFirstListWithItems(document, "Child one", "Child two"); + assertNotNull(nestedList, html); + assertEquals(2, nestedList.children().size(), html); + // Only the top-level leading "

Notes:

" should exist; no nested prose paragraphs. + assertEquals(1, document.select("p").size(), "no spurious prose paragraphs, html=" + html); + assertEquals("Notes:", document.select("p").first().text(), html); + } + + private static Document parseFragment(String html) { + Document document = Jsoup.parseBodyFragment(html); + document.outputSettings().prettyPrint(false); + return document; + } + + private static int indexOfParagraphContaining(Document document, String text) { + Elements bodyChildren = document.body().children(); + for (int index = 0; index < bodyChildren.size(); index++) { + Element bodyChild = bodyChildren.get(index); + if (bodyChild.tagName().equals("p") && bodyChild.text().contains(text)) { + return index; + } + } + return -1; + } + + private static int indexOfFirstListContaining(Document document, String itemText) { + Elements bodyChildren = document.body().children(); + for (int index = 0; index < bodyChildren.size(); index++) { + Element bodyChild = bodyChildren.get(index); + if (isListElement(bodyChild) && listContainsItemText(bodyChild, itemText)) { + return index; + } + } + return -1; + } + + private static boolean isListElement(Element element) { + return element.tagName().equals("ol") || element.tagName().equals("ul"); + } + + private static boolean listContainsItemText(Element listElement, String itemText) { + for (Element listItem : listElement.children()) { + if (listItem.text().contains(itemText)) { + return true; + } + } + return false; + } + + private static Element findFirstListWithItems(Document document, String... itemTexts) { + for (Element listElement : document.select("ol, ul")) { + boolean allPresent = true; + for (String expected : itemTexts) { + if (!listContainsExactItem(listElement, expected)) { + allPresent = false; + break; + } + } + if (allPresent) { + return listElement; + } + } + return null; + } + + private static boolean listContainsExactItem(Element listElement, String expected) { + for (Element listItem : listElement.children()) { + if (listItem.text().equals(expected)) { + return true; + } + } + return false; + } +}