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..a3046ecf 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -179,7 +179,10 @@ def test(self, parent: etree.Element, block: str) -> bool: (parent[-1].tag in self.LIST_TYPES))) def run(self, parent: etree.Element, blocks: list[str]) -> None: + # Whether this block starts with items split off an indented block (see the end of this method). + continued = self.parser.state.isstate('listcontinue') block = blocks.pop(0) + block, rest = self.split_list_items(parent, block) level, sibling = self.get_level(parent, block) block = self.looseDetab(block, level) @@ -209,10 +212,51 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None: p.text = sibling[-1].text sibling[-1].text = '' sibling[-1].insert(0, p) - self.parser.parseChunk(sibling[-1], block) + if continued: + # The items continue the nested list in this item, so they are tight too. + self.parser.state.set('listcontinue') + self.parser.parseChunk(sibling[-1], block) + self.parser.state.reset() + else: + self.parser.parseChunk(sibling[-1], block) 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 list items which end an indented block. + + Return the part of `block` before the first line which is indented less than the first + line and starts another item of the list at its level of indentation, and the rest, + which is empty if there is no such line. + """ + if parent.tag in self.ITEM_TYPES: + return block, '' + # The list at each level of indentation: the list in `parent`, the list nested in its + # last item, and so on. + lists = [] + lst = parent[-1] + while lst is not None and lst.tag in self.LIST_TYPES: + lists.append(lst) + lst = self.lastChild(lst[-1]) if len(lst) else None + block_level = len(self.INDENT_RE.match(block).group(1)) // self.tab_length + lines = block.split('\n') + for i, line in enumerate(lines[1:], start=1): + m = self.INDENT_RE.match(line) + level = len(m.group(1)) // self.tab_length if m else 0 + if level >= min(block_level, len(lists)): + continue + for processor in self.parser.blockprocessors: + if (isinstance(processor, OListProcessor) and lists[level].tag in processor.SIBLING_TAGS and + processor.test(parent, line[level * self.tab_length:])): + 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 +405,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..fb8ba2a7 100644 --- a/tests/test_syntax/blocks/test_ul.py +++ b/tests/test_syntax/blocks/test_ul.py @@ -135,3 +135,190 @@ 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( + """ + +
+ """ + ) + ) + + # The same applies to a nested list item after an indented paragraph of the previous + # nested item. The items after it, at any level, are tight. + def test_nested_item_after_indented_paragraph_no_blank_line(self): + self.assertMarkdownRenders( + self.dedent( + """ + - item one + - sub-item one + + second paragraph of sub-item 1 + - sub-item two + - item two + """ + ), + self.dedent( + """ + + """ + ) + ) diff --git a/tests/test_syntax/extensions/test_sane_lists.py b/tests/test_syntax/extensions/test_sane_lists.py new file mode 100644 index 00000000..609ee326 --- /dev/null +++ b/tests/test_syntax/extensions/test_sane_lists.py @@ -0,0 +1,52 @@ +""" +Python Markdown + +A Python implementation of John Gruber's Markdown. + +Documentation: https://python-markdown.github.io/ +GitHub: https://github.com/Python-Markdown/markdown/ +PyPI: https://pypi.org/project/Markdown/ + +Started by Manfred Stienstra (http://www.dwerg.net/). +Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +Currently maintained by Waylan Limberg (https://github.com/waylan), +Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +Copyright 2007-2024 The Python Markdown Project (v. 1.7 and later) +Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +Copyright 2004 Manfred Stienstra (the original version) + +License: BSD (see LICENSE.md for details). +""" + +from markdown.test_tools import TestCase + + +class TestSaneLists(TestCase): + + default_kwargs = {'extensions': ['sane_lists']} + + # A marker of the other list type does not start an item, so it does not end the + # indented block before it (#845). + def test_other_list_type_after_indented_paragraph(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. +
+ """ + ) + )