Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 51 additions & 2 deletions markdown/blockprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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. """
Expand Down Expand Up @@ -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,
Expand Down
187 changes: 187 additions & 0 deletions tests/test_syntax/blocks/test_ul.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
<ul>
<li>
<p>item one</p>
<p>second paragraph of item 1</p>
</li>
<li>item two</li>
<li>item three</li>
</ul>
"""
)
)

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(
"""
<ol>
<li>
<p>item one</p>
<p>second paragraph of item 1</p>
</li>
<li>item two</li>
</ol>
"""
)
)

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(
"""
<ul>
<li>
<p>First item:</p>
<ul>
<li>First sub-item</li>
<li>Second sub-item</li>
</ul>
<p>Additional paragraph in first item</p>
</li>
<li>Second item</li>
</ul>
"""
)
)

# 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(
"""
<ul>
<li>
<p>item one</p>
<p>second paragraph of item 1
line two of second paragraph of item 1</p>
</li>
</ul>
"""
)
)

def test_item_after_indented_paragraph_in_blockquote(self):
self.assertMarkdownRenders(
self.dedent(
"""
> - item one
>
> second paragraph of item 1
> - item two
"""
),
self.dedent(
"""
<blockquote>
<ul>
<li>
<p>item one</p>
<p>second paragraph of item 1</p>
</li>
<li>item two</li>
</ul>
</blockquote>
"""
)
)

# 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(
"""
<ul>
<li>
<p>item one</p>
<p>second paragraph of item 1</p>
</li>
</ul>
<hr />
"""
)
)

# 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(
"""
<ul>
<li>
<p>item one</p>
<ul>
<li>
<p>sub-item one</p>
<p>second paragraph of sub-item 1</p>
</li>
<li>sub-item two</li>
</ul>
</li>
<li>item two</li>
</ul>
"""
)
)
52 changes: 52 additions & 0 deletions tests/test_syntax/extensions/test_sane_lists.py
Original file line number Diff line number Diff line change
@@ -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(
"""
<ol>
<li>
<p>item one</p>
<p>second paragraph of item 1
- not an item</p>
</li>
</ol>
"""
)
)