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 1/2] 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(
+ """
+
+ -
+
item one
+ second paragraph of item 1
+
+ - item two
+
+ """
+ )
+ )
+
+ 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(
+ """
+
+ -
+
item one
+ second paragraph of item 1
+ - not an item
+
+
+ """
+ ),
+ extensions=['sane_lists']
+ )
From 18265579e5f5085fa0b69a54f6688f849fcc8b16 Mon Sep 17 00:00:00 2001
From: Gabriel <189647823+gabesgh@users.noreply.github.com>
Date: Thu, 22 Oct 2026 16:54:45 -0300
Subject: [PATCH 2/2] Split off outdented items at nested list levels too
An item of a nested list which directly follows an indented paragraph
of the previous nested item, without a blank line, was still merged
into that paragraph. Split an indented block at the first line which
is indented less than the block and starts an item of the list at that
level of indentation, not only at unindented lines.
When the rest is still indented, it is split again in the same way,
and its items continue the nested list as tight items, as at the top
level.
Also move the sane_lists test to a test file for the extension.
Refs #845.
---
markdown/blockprocessors.py | 32 +++++++++---
tests/test_syntax/blocks/test_ul.py | 30 +++++++----
.../test_syntax/extensions/test_sane_lists.py | 52 +++++++++++++++++++
3 files changed, 97 insertions(+), 17 deletions(-)
create mode 100644 tests/test_syntax/extensions/test_sane_lists.py
diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py
index 75486c77..a3046ecf 100644
--- a/markdown/blockprocessors.py
+++ b/markdown/blockprocessors.py
@@ -179,6 +179,8 @@ 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)
@@ -210,7 +212,13 @@ 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()
@@ -222,19 +230,31 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None:
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.
+ """ Split off the 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.
+ 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 lst.tag in processor.SIBLING_TAGS and
- processor.test(parent, line)):
+ 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, ''
diff --git a/tests/test_syntax/blocks/test_ul.py b/tests/test_syntax/blocks/test_ul.py
index 1088968b..fb8ba2a7 100644
--- a/tests/test_syntax/blocks/test_ul.py
+++ b/tests/test_syntax/blocks/test_ul.py
@@ -290,27 +290,35 @@ def test_hr_after_indented_paragraph(self):
)
)
- # 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):
+ # 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(
"""
- 1. item one
+ - item one
+ - sub-item one
- second paragraph of item 1
- - not an item
+ second paragraph of sub-item 1
+ - sub-item two
+ - item two
"""
),
self.dedent(
"""
-
+
+ sub-item two
+
+
+ item two
+
"""
- ),
- extensions=['sane_lists']
+ )
)
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(
+ """
+
+ -
+
item one
+ second paragraph of item 1
+ - not an item
+
+
+ """
+ )
+ )