diff --git a/docs/changelog.md b/docs/changelog.md index 94b4a2f5..ed015393 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -23,6 +23,8 @@ See the [Contributing Guide](contributing.md) for details. and closing backticks did not match (#1668). * Escape the language name in the `codehilite` extension's non-Pygments output to maintain consistency with Pygments output. +* Fix a list item being merged into the previous item when it directly follows an + indented paragraph or nested list of that item without a blank line (#845). ## [3.11.2] - 2026-10-19 diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index cbad917b..75486c77 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -180,6 +180,7 @@ def test(self, parent: etree.Element, block: str) -> bool: def run(self, parent: etree.Element, blocks: list[str]) -> None: block = blocks.pop(0) + block, rest = self.split_list_items(parent, block) level, sibling = self.get_level(parent, block) block = self.looseDetab(block, level) @@ -213,6 +214,29 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None: else: self.create_item(sibling, block) self.parser.state.reset() + if rest: + # No blank line comes before these items, so they continue the list as + # tight items (see `OListProcessor`). + self.parser.state.set('listcontinue') + self.parser.parseBlocks(parent, [rest]) + self.parser.state.reset() + + def split_list_items(self, parent: etree.Element, block: str) -> tuple[str, str]: + """ Split off the unindented list items which end an indented block. + + Return the part of `block` before the first unindented line which starts another + item of the list in `parent`, and the rest, which is empty if there is no such line. + """ + if parent.tag in self.ITEM_TYPES: + return block, '' + lst = parent[-1] + lines = block.split('\n') + for i, line in enumerate(lines[1:], start=1): + for processor in self.parser.blockprocessors: + if (isinstance(processor, OListProcessor) and lst.tag in processor.SIBLING_TAGS and + processor.test(parent, line)): + return '\n'.join(lines[:i]), '\n'.join(lines[i:]) + return block, '' def create_item(self, parent: etree.Element, block: str) -> None: """ Create a new `li` and parse the block with it as the parent. """ @@ -361,7 +385,12 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None: items = self.get_items(blocks.pop(0)) sibling = self.lastChild(parent) - if sibling is not None and sibling.tag in self.SIBLING_TAGS: + if (sibling is not None and sibling.tag in self.SIBLING_TAGS and + self.parser.state.isstate('listcontinue')): + # These items directly follow an indented block of the previous item + # without a blank line (see `ListIndentProcessor`), so they are tight. + lst = sibling + elif sibling is not None and sibling.tag in self.SIBLING_TAGS: # Previous block was a list item, so set that as parent lst = sibling # make sure previous item is in a `p` - if the item has text, diff --git a/tests/test_syntax/blocks/test_ul.py b/tests/test_syntax/blocks/test_ul.py index f90d43a2..1088968b 100644 --- a/tests/test_syntax/blocks/test_ul.py +++ b/tests/test_syntax/blocks/test_ul.py @@ -135,3 +135,182 @@ def test_header_and_paragraph_no_blank_line_tight_list_no_indent(self): """ ) ) + + # An unindented list item ends the indented block before it, even without a blank + # line. As no blank line comes before the new item, it is not a loose item (#845). + def test_item_after_indented_paragraph_no_blank_line(self): + self.assertMarkdownRenders( + self.dedent( + """ + - item one + + second paragraph of item 1 + - item two + - item three + """ + ), + self.dedent( + """ + + """ + ) + ) + + def test_ordered_item_after_indented_paragraph_no_blank_line(self): + self.assertMarkdownRenders( + self.dedent( + """ + 1. item one + + second paragraph of item 1 + 2. item two + """ + ), + self.dedent( + """ +
    +
  1. +

    item one

    +

    second paragraph of item 1

    +
  2. +
  3. item two
  4. +
+ """ + ) + ) + + def test_item_after_nested_list_and_paragraph_no_blank_line(self): + self.assertMarkdownRenders( + self.dedent( + """ + * First item: + + * First sub-item + * Second sub-item + + Additional paragraph in first item + * Second item + """ + ), + self.dedent( + """ + + """ + ) + ) + + # An unindented line which does not start a list item is still a lazy + # continuation of the paragraph. + def test_lazy_paragraph_continuation_after_indented_paragraph(self): + self.assertMarkdownRenders( + self.dedent( + """ + - item one + + second paragraph of item 1 + line two of second paragraph of item 1 + """ + ), + self.dedent( + """ + + """ + ) + ) + + def test_item_after_indented_paragraph_in_blockquote(self): + self.assertMarkdownRenders( + self.dedent( + """ + > - item one + > + > second paragraph of item 1 + > - item two + """ + ), + self.dedent( + """ +
+ +
+ """ + ) + ) + + # As with a list at the top level, a horizontal rule ends the list. + def test_hr_after_indented_paragraph(self): + self.assertMarkdownRenders( + self.dedent( + """ + - item one + + second paragraph of item 1 + * * * + """ + ), + self.dedent( + """ + +
+ """ + ) + ) + + # With `sane_lists`, a marker of the other list type does not start an item. + def test_other_list_type_after_indented_paragraph_sane_lists(self): + self.assertMarkdownRenders( + self.dedent( + """ + 1. item one + + second paragraph of item 1 + - not an item + """ + ), + self.dedent( + """ +
    +
  1. +

    item one

    +

    second paragraph of item 1 + - not an item

    +
  2. +
+ """ + ), + extensions=['sane_lists'] + )