From 58ffef096b073ed5bc144a4f8f08a1eabe484983 Mon Sep 17 00:00:00 2001 From: Gabriel <189647823+gabesgh@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:24:51 -0300 Subject: [PATCH] Split list items off an indented block of the previous item A list item which directly followed an indented paragraph or nested list of the previous item, without a blank line between them, was treated as a lazy continuation line of that block (#845). `ListIndentProcessor` now splits the block before the first unindented line which starts another item of the list. The registered list processors decide what counts as an item, so `sane_lists` still treats a marker of the other list type as text. Other unindented lines remain lazy continuations. As no blank line comes before them, the split off items continue the list as tight items: a `listcontinue` parser state tells `OListProcessor` not to make the first of them loose. They are parsed like any list block at that level, so a horizontal rule or header after them now ends the list, as it already does at the top level. Fixes #845. --- docs/changelog.md | 2 + markdown/blockprocessors.py | 31 ++++- tests/test_syntax/blocks/test_ul.py | 179 ++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) 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'] + )