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
10 changes: 10 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ and this project adheres to the
[Python Version Specification](https://packaging.python.org/en/latest/specifications/version-specifiers/).
See the [Contributing Guide](contributing.md) for details.

## [unreleased]

### Fixed

* Treat block-level raw HTML tags which are indented more than three spaces as
raw HTML blocks when they are nested within a containing block (a list item,
admonition, etc.), instead of incorrectly splitting them into separate
paragraphs at blank lines. Raw HTML blocks within indented code blocks are
still treated as code (#1096).

## [3.11.2] - 2026-10-19

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions markdown/extensions/md_in_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def handle_starttag(self, tag, attrs):

if (
tag in self.block_level_tags and
(self.at_line_start() or self.intail or self.mdstarted and self.mdstarted[-1])
(self.at_line_start() or self.indented_line_start() or self.intail or self.mdstarted and self.mdstarted[-1])
):
# Valueless attribute (ex: `<tag checked>`) results in `[('checked', None)]`.
# Convert to `{'checked': 'checked'}`.
Expand Down Expand Up @@ -279,7 +279,7 @@ def handle_empty_tag(self, data, is_block):
self.handle_data(data)

def parse_pi(self, i: int) -> int:
if self.at_line_start() or self.intail or self.mdstack:
if self.at_line_start() or self.indented_line_start() or self.intail or self.mdstack:
# The same override exists in `HTMLExtractor` without the check
# for `mdstack`. Therefore, use parent of `HTMLExtractor` instead.
return super(HTMLExtractor, self).parse_pi(i)
Expand All @@ -289,7 +289,7 @@ def parse_pi(self, i: int) -> int:
return i + 2

def parse_html_declaration(self, i: int) -> int:
if self.at_line_start() or self.intail or self.mdstack:
if self.at_line_start() or self.indented_line_start() or self.intail or self.mdstack:
if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[':
# We have encountered the bug in #1534 (Python bug `gh-77057`).
# Provide an override until we drop support for Python < 3.13.
Expand Down
126 changes: 118 additions & 8 deletions markdown/htmlparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
import sys
from typing import TYPE_CHECKING, Sequence

from . import util

if TYPE_CHECKING: # pragma: no cover
from markdown import Markdown

Expand Down Expand Up @@ -152,6 +154,7 @@ def __init__(self, md: Markdown, *args, **kwargs):
def reset(self):
"""Reset this instance. Loses all unprocessed data."""
self.inraw = False
self.inraw_indent = 0
self.intail = False
self.stack: list[str] = [] # When `inraw==True`, stack contains a list of tags
self.inline_stack: list[str] = [] # Unclosed raw inline tags in the current paragraph
Expand All @@ -173,8 +176,7 @@ def close(self):
self.handle_data(self.rawdata)
# Handle any unclosed tags.
if len(self._cache):
self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
self._cache = []
self.stash_raw_block()

@property
def line_offset(self) -> int:
Expand Down Expand Up @@ -202,6 +204,76 @@ def at_line_start(self) -> bool:
# Confirm up to first 3 chars are whitespace
return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == ''

def indented_line_start(self) -> bool:
"""
Returns True if current position is at the start of a line which is
preceded by more than three spaces of indentation.

A block-level tag preceded by such indentation may be nested within a
containing block (a list item, admonition, etc.) rather than being part
of an indented code block. The indentation is preserved on the line
which holds the placeholder so the block parser can make that decision.
"""
if self.offset <= 3:
return False
return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == ''

def strip_leading_indent(self) -> None:
"""
Remove the whitespace preceding the current position from `cleandoc`.

The whitespace is re-added to the placeholder line when the raw block
is stashed (see `get_stash_prefix`). This ensures that the placeholder
retains the indentation of the original start tag.
"""
remaining = self.offset
while remaining and self.cleandoc:
chunk = self.cleandoc[-1]
cut = len(chunk) - len(chunk.rstrip(' '))
if not cut:
break
take = min(cut, remaining)
self.cleandoc[-1] = chunk[:-take]
remaining -= take

def get_stash_prefix(self) -> str:
""" Returns the indentation to prefix the raw block placeholder with. """
return ' ' * self.inraw_indent

def dedent_raw(self, text: str) -> str:
"""
Remove the indentation of the raw block's start tag from each line of
the raw block's content (after the first line).
"""
if not self.inraw_indent:
return text
lines = text.split('\n')
for i in range(1, len(lines)):
line = lines[i]
j = 0
while j < self.inraw_indent and j < len(line) and line[j] == ' ':
j += 1
lines[i] = line[j:]
return '\n'.join(lines)

def stash_raw_block(self) -> None:
"""
Store the current raw block in the `htmlStash` and insert its
placeholder into `cleandoc`.

For an indented raw block, the placeholder line preserves the
indentation of the start tag and the stashed content records that
indentation (see [`IndentedRawHtml`][markdown.util.IndentedRawHtml]).
"""
content = self.dedent_raw(''.join(self._cache))
if self.inraw_indent:
html = util.IndentedRawHtml(content, self.inraw_indent)
else:
html = content
self.cleandoc.append(self.get_stash_prefix() + self.md.htmlStash.store(html))
self._cache = []
self.inraw_indent = 0

def get_endtag_text(self, tag: str) -> str:
"""
Returns the text of the end tag.
Expand All @@ -223,10 +295,20 @@ def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]):
self.handle_startendtag(tag, attrs)
return

if self.md.is_block_level(tag) and (self.intail or (self.at_line_start() and not self.inraw)):
if self.md.is_block_level(tag) and (
self.intail or ((self.at_line_start() or self.indented_line_start()) and not self.inraw)
):
# Started a new raw block. Prepare stack.
self.inraw = True
self.cleandoc.append('\n')
if self.intail or self.at_line_start():
self.inraw_indent = 0
self.cleandoc.append('\n')
else:
# The start tag is indented. Preserve the indentation on the
# placeholder line so the block parser can determine whether
# this is a nested raw block or an indented code block.
self.inraw_indent = self.offset
self.strip_leading_indent()

text = self.get_starttag_text()
if self.inraw:
Expand Down Expand Up @@ -254,15 +336,22 @@ def handle_endtag(self, tag: str):
if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(text):]):
# Preserve blank line and end of raw block.
self._cache.append('\n')
if self.inraw_indent:
# The raw block was indented. The blank line which
# follows in the source separates the placeholder from
# any following content, so no extra newlines are
# needed here.
self.inraw = False
self.stash_raw_block()
return
else:
# More content exists after `endtag`.
self.intail = True
# Reset stack.
self.inraw = False
self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
self.stash_raw_block()
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
self._cache = []
else:
self.cleandoc.append(text)
self.close_inline_tag(tag)
Expand Down Expand Up @@ -317,6 +406,27 @@ def handle_empty_tag(self, data: str, is_block: bool):
self.cleandoc.append(self.md.htmlStash.store(data))
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
elif self.indented_line_start() and is_block:
# Handle this as a standalone indented raw block. The indentation
# is preserved on the placeholder line so the block parser can
# determine whether this is nested content or a code block.
self.inraw_indent = self.offset
if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(data):]):
# Preserve blank line after tag in raw block. The blank line
# which follows in the source separates the placeholder from
# any following content, so no extra newlines are needed here.
data += '\n'
self.strip_leading_indent()
self._cache.append(data)
self.stash_raw_block()
else:
# More content exists after tag.
self.intail = True
self.strip_leading_indent()
self._cache.append(data)
self.stash_raw_block()
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
else:
self.cleandoc.append(data)

Expand Down Expand Up @@ -347,7 +457,7 @@ def unknown_decl(self, data: str):
self.handle_empty_tag('<![{}{}'.format(data, end), is_block=True)

def parse_pi(self, i: int) -> int:
if self.at_line_start() or self.intail:
if self.at_line_start() or self.indented_line_start() or self.intail:
return super().parse_pi(i)
# This is not the beginning of a raw block so treat as plain data
# and avoid consuming any tags which may follow (see #1066).
Expand All @@ -369,7 +479,7 @@ def parse_comment(self, i, report=True):
return match.end()

def parse_html_declaration(self, i: int) -> int:
if self.at_line_start() or self.intail:
if self.at_line_start() or self.indented_line_start() or self.intail:
if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[':
# We have encountered the bug in #1534 (Python bug `gh-77057`).
# Provide an override until we drop support for Python < 3.13.
Expand Down
33 changes: 32 additions & 1 deletion markdown/postprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from __future__ import annotations

from html import escape
from typing import TYPE_CHECKING, Any
from . import util
import re
Expand Down Expand Up @@ -70,6 +71,10 @@ class RawHtmlPostprocessor(Postprocessor):

BLOCK_LEVEL_REGEX = re.compile(r'^\<\/?([^ >]+)')

# Match regions of code (`<pre>` and `<code>` elements). A raw HTML
# placeholder within such a region must be restored as escaped text.
CODE_REGION_RE = re.compile(r'<(pre|code)[\s>].*?</\1\s*>', re.DOTALL)

def run(self, text: str) -> str:
""" Iterate over html stash and restore html. """
def substitute_match(m: re.Match[str]) -> str:
Expand All @@ -80,7 +85,29 @@ def substitute_match(m: re.Match[str]) -> str:
wrapped = False
if (key := int(key)) >= self.md.htmlStash.html_counter:
return m.group(0)
html = self.stash_to_string(self.md.htmlStash.rawHtmlBlocks[key])
entry = self.md.htmlStash.rawHtmlBlocks[key]
html = self.stash_to_string(entry)
if self.in_code_region(m.string, m.start()):
# The placeholder is within a code block. Restore the HTML as
# escaped text so it displays as content rather than markup.
# The code block adds a trailing newline itself and only
# strips `tab_length` from each line, so re-indent the content
# accordingly.
if isinstance(entry, util.IndentedRawHtml):
extra = entry.indent - self.md.tab_length
if extra > 0:
# The first line is the start tag. Its indentation is
# already retained by the placeholder line itself (the
# code block only strips `tab_length` from it), so
# only the following lines are re-indented.
lines = html.split('\n')
html = '\n'.join([lines[0]] + [
' ' * extra + line if line else line
for line in lines[1:]
])
if html.endswith('\n'):
html = html[:-1]
return escape(html, quote=False)
if not wrapped or self.isblocklevel(html):
return pattern.sub(substitute_match, html)
return pattern.sub(substitute_match, f"<p>{html}</p>")
Expand All @@ -92,6 +119,10 @@ def substitute_match(m: re.Match[str]) -> str:
else:
return text

def in_code_region(self, text: str, pos: int) -> bool:
""" Check if position `pos` in `text` is within a `<pre>` or `<code>` region. """
return any(m.start() < pos < m.end() for m in self.CODE_REGION_RE.finditer(text))

def isblocklevel(self, html: str) -> bool:
""" Check is block of HTML is block-level. """
m = self.BLOCK_LEVEL_REGEX.match(html)
Expand Down
21 changes: 21 additions & 0 deletions markdown/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,27 @@ class TagData(TypedDict):
right_index: int


class IndentedRawHtml(str):
"""
A raw HTML block which was extracted from a line indented by more than
`tab_length` spaces.

The content is stored with the indentation of its start tag removed and
the `indent` attribute preserves the number of spaces which were removed.
Consumers (such as `RawHtmlPostprocessor`) can use `indent` to restore
the appropriate indentation for the context in which the placeholder ends
up (for example, within an indented code block). Otherwise it behaves as
a normal string.
"""

def __new__(cls, html: str, indent: int):
obj = super().__new__(cls, html)
obj.indent = indent
return obj

indent: int


class HtmlStash:
"""
This class is used for stashing HTML objects that we extract
Expand Down
60 changes: 60 additions & 0 deletions tests/test_syntax/blocks/test_html_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,66 @@ def test_raw_indent_four_spaces(self):
)
)

def test_indented_code_block_with_blank_lines(self):
# Ensure an indented HTML block with blank lines remains a code block.
# See #1096.
self.assertMarkdownRenders(
self.dedent(
"""
A paragraph.

<div>
code

more code
</div>
"""
),
self.dedent(
"""
<p>A paragraph.</p>
<pre><code>&lt;div&gt;
code

more code
&lt;/div&gt;
</code></pre>
"""
)
)

def test_raw_indented_in_list_item(self):
# A raw HTML block nested in a list item should not be split into
# separate paragraphs at blank lines. See #1096.
self.assertMarkdownRenders(
self.dedent(
"""
* A list item with a nested raw HTML block.

<div>
Some text

Some more text
</div>
"""
),
self.dedent(
"""
<ul>
<li>
<p>A list item with a nested raw HTML block.</p>
<div>
Some text

Some more text
</div>

</li>
</ul>
"""
)
)

def test_raw_span(self):
self.assertMarkdownRenders(
'<span>*inline*</span>',
Expand Down
Loading