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
31 changes: 30 additions & 1 deletion markdown/blockprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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. """
Expand Down Expand Up @@ -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,
Expand Down
179 changes: 179 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,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(
"""
<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 />
"""
)
)

# 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(
"""
<ol>
<li>
<p>item one</p>
<p>second paragraph of item 1
- not an item</p>
</li>
</ol>
"""
),
extensions=['sane_lists']
)