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 @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details.

* Update serializer to be non-recursive (#1644).
* Improve ancestor handling in the inline `Treeprocessor` (#1646).
* Keep a raw HTML comment inside an inline HTML element that closes in the
same paragraph, instead of splitting the paragraph around it (#1643).
* Fix issue where inline HTML attributes were rejected if they had `<` or `>` in the attribute (#1647).

## [3.11.0] - 2026-09-25
Expand Down
2 changes: 2 additions & 0 deletions markdown/extensions/md_in_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def handle_starttag(self, tag, attrs):
self.handle_data(self.md.htmlStash.store(text))
else:
self.handle_data(text)
self.open_inline_tag(tag)
if tag in self.CDATA_CONTENT_ELEMENTS:
# This is presumably a standalone tag in a code span (see #1036).
self.clear_cdata_mode()
Expand Down Expand Up @@ -242,6 +243,7 @@ def handle_endtag(self, tag):
self.handle_data(self.md.htmlStash.store(text))
else:
self.handle_data(text)
self.close_inline_tag(tag)

def handle_startendtag(self, tag, attrs):
if tag in self.empty_tags:
Expand Down
38 changes: 37 additions & 1 deletion markdown/htmlparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@
# The newlines may be preceded by additional whitespace.
blank_line_re = re.compile(r'^([ ]*\n){2}')

# Match a blank line anywhere in a run of text.
inner_blank_line_re = re.compile(r'\n[ \t]*\n')

# Elements which never have an end tag and so cannot contain a comment.
void_tags = frozenset([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'
])


class _HTMLParser(htmlparser.HTMLParser):
"""Handle special start and end tags."""
Expand Down Expand Up @@ -144,6 +152,7 @@ def reset(self):
self.inraw = False
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
self._cache: list[str] = []
self.cleandoc: list[str] = []
self.lineno_start_cache = [0]
Expand Down Expand Up @@ -223,6 +232,7 @@ def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]):
self._cache.append(text)
else:
self.cleandoc.append(text)
self.open_inline_tag(tag)
if tag in self.CDATA_CONTENT_ELEMENTS:
# This is presumably a standalone tag in a code span (see #1036).
self.clear_cdata_mode()
Expand Down Expand Up @@ -253,13 +263,36 @@ def handle_endtag(self, tag: str):
self._cache = []
else:
self.cleandoc.append(text)
self.close_inline_tag(tag)

def open_inline_tag(self, tag: str):
""" Track an unclosed raw inline tag so a comment inside it is not treated as a block. """
if not self.md.is_block_level(tag) and tag not in void_tags:
self.inline_stack.append(tag)

def close_inline_tag(self, tag: str):
""" Stop tracking an inline tag and any unclosed inline tags opened after it. """
if tag in self.inline_stack:
while self.inline_stack:
if self.inline_stack.pop() == tag:
break

def inline_close_follows(self, text: str) -> bool:
""" Return `True` if an open inline tag is closed in the same paragraph after the comment `text`. """
if not self.inline_stack:
return False
rest = inner_blank_line_re.split(self.rawdata[self.line_offset + self.offset + len(text):], 1)[0]
return any(re.search(r'</\s*{}\s*>'.format(re.escape(tag)), rest, re.I) for tag in self.inline_stack)

def handle_data(self, data: str):
if self.intail and '\n' in data:
self.intail = False
if self.inraw:
self._cache.append(data)
else:
if inner_blank_line_re.search(data):
# A blank line ends the paragraph, so open inline tags no longer enclose what follows.
self.inline_stack = []
self.cleandoc.append(data)

def handle_empty_tag(self, data: str, is_block: bool):
Expand Down Expand Up @@ -296,7 +329,10 @@ def handle_entityref(self, name: str):

def handle_comment(self, data: str):
# Check if the comment is unclosed, if so, we need to override position
self.handle_empty_tag('<!--{}-->'.format(data), is_block=True)
text = '<!--{}-->'.format(data)
# A comment inside a raw inline element is part of that inline content, unless it contains a blank line.
is_block = bool(inner_blank_line_re.search(data)) or not self.inline_close_follows(text)
self.handle_empty_tag(text, is_block=is_block)

def handle_decl(self, data: str):
self.handle_empty_tag('<!{}>'.format(data), is_block=True)
Expand Down
161 changes: 161 additions & 0 deletions tests/test_syntax/blocks/test_html_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,167 @@ def test_comment_in_code_span(self):
'<p><code>&lt;!-- *foo* --&gt;</code></p>'
)

def test_comment_in_inline_html_own_line(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>
<!-- comment -->
</span>
"""
),
self.dedent(
"""
<p><span>
<!-- comment -->
</span></p>
"""
)
)

def test_comment_in_inline_html_same_line_as_start_tag(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span><!-- comment -->
</span>
"""
),
self.dedent(
"""
<p><span><!-- comment -->
</span></p>
"""
)
)

def test_comment_in_inline_html_own_line_before_endtag(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>
<!-- comment --></span>
"""
),
self.dedent(
"""
<p><span>
<!-- comment --></span></p>
"""
)
)

def test_comment_in_inline_html_after_blank_line(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>

<!-- comment -->

</span>
"""
),
self.dedent(
"""
<p><span></p>
<!-- comment -->

<p></span></p>
"""
)
)

def test_comment_after_closed_inline_html(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>foo</span>
<!-- comment -->
"""
),
self.dedent(
"""
<p><span>foo</span></p>
<!-- comment -->
"""
)
)

def test_comment_after_unclosed_inline_tag_in_code_span(self):
self.assertMarkdownRenders(
self.dedent(
"""
Use `<b>` for bold.
<!-- comment -->
"""
),
self.dedent(
"""
<p>Use <code>&lt;b&gt;</code> for bold.</p>
<!-- comment -->
"""
)
)

def test_comment_after_unclosed_inline_html(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>foo
<!-- comment -->
"""
),
self.dedent(
"""
<p><span>foo</p>
<!-- comment -->
"""
)
)

def test_comment_with_blank_line_in_inline_html(self):
self.assertMarkdownRenders(
self.dedent(
"""
<span>
<!--
multi

line
-->
</span>
"""
),
self.dedent(
"""
<p><span></p>
<!--
multi

line
-->
<p></span></p>
"""
)
)

def test_comment_after_void_inline_html(self):
self.assertMarkdownRenders(
self.dedent(
"""
<img src="a.png">
<!-- comment -->
"""
),
self.dedent(
"""
<p><img src="a.png"></p>
<!-- comment -->
"""
)
)

def test_raw_comment_one_line_followed_by_text(self):
self.assertMarkdownRenders(
'<!-- *foo* -->*bar*',
Expand Down
Loading