From 6b8d1e80797074e5e630c58675fea51433097ab8 Mon Sep 17 00:00:00 2001 From: inchang-ing <197932532+inchang-ing@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:37:17 +0800 Subject: [PATCH] Fix HTML blocks nested in indented blocks Raw HTML blocks which start on a line indented by more than three spaces were not extracted by the HTML block preprocessor, as it could not distinguish such a start tag nested within a containing block (a list item, admonition, etc.) from an indented code block. The block parser then treated the HTML as inline content and split it into separate paragraphs at any blank lines within the raw block. The preprocessor now extracts such indented raw blocks, removes the indentation of the start tag from the stashed content, and preserves that indentation on the line which holds the placeholder. The block parser can then make the decision based on the resulting block structure: within a containing block the placeholder is parsed as nested content, whereas an indented code block keeps the placeholder in its content. In the latter case, the RawHtmlPostprocessor escapes the stashed HTML, so indented code blocks are rendered exactly as before. Fixes #1096 --- docs/changelog.md | 10 ++ markdown/extensions/md_in_html.py | 6 +- markdown/htmlparser.py | 126 ++++++++++++++++-- markdown/postprocessors.py | 33 ++++- markdown/util.py | 21 +++ tests/test_syntax/blocks/test_html_blocks.py | 60 +++++++++ .../test_syntax/extensions/test_admonition.py | 30 +++++ 7 files changed, 274 insertions(+), 12 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index dcb63628..d865ac13 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/markdown/extensions/md_in_html.py b/markdown/extensions/md_in_html.py index 1a4ec0e2..45494466 100644 --- a/markdown/extensions/md_in_html.py +++ b/markdown/extensions/md_in_html.py @@ -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: ``) results in `[('checked', None)]`. # Convert to `{'checked': 'checked'}`. @@ -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) @@ -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] == ' int: @@ -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. @@ -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: @@ -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) @@ -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) @@ -347,7 +457,7 @@ def unknown_decl(self, data: str): self.handle_empty_tag(' 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). @@ -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] == ']+)') + # Match regions of code (`
` and `` elements). A raw HTML
+    # placeholder within such a region must be restored as escaped text.
+    CODE_REGION_RE = re.compile(r'<(pre|code)[\s>].*?', re.DOTALL)
+
     def run(self, text: str) -> str:
         """ Iterate over html stash and restore html. """
         def substitute_match(m: re.Match[str]) -> str:
@@ -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"

{html}

") @@ -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 `
` or `` 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)
diff --git a/markdown/util.py b/markdown/util.py
index 29ffa247..b0786c80 100644
--- a/markdown/util.py
+++ b/markdown/util.py
@@ -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
diff --git a/tests/test_syntax/blocks/test_html_blocks.py b/tests/test_syntax/blocks/test_html_blocks.py
index e68f6d76..d90707c2 100644
--- a/tests/test_syntax/blocks/test_html_blocks.py
+++ b/tests/test_syntax/blocks/test_html_blocks.py
@@ -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.
+
+                    
+ code + + more code +
+ """ + ), + self.dedent( + """ +

A paragraph.

+
<div>
+                code
+
+                more code
+                </div>
+                
+ """ + ) + ) + + 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. + +
+ Some text + + Some more text +
+ """ + ), + self.dedent( + """ +
    +
  • +

    A list item with a nested raw HTML block.

    +
    + Some text + + Some more text +
    + +
  • +
+ """ + ) + ) + def test_raw_span(self): self.assertMarkdownRenders( '*inline*', diff --git a/tests/test_syntax/extensions/test_admonition.py b/tests/test_syntax/extensions/test_admonition.py index 268633dd..ac4d25b8 100644 --- a/tests/test_syntax/extensions/test_admonition.py +++ b/tests/test_syntax/extensions/test_admonition.py @@ -263,3 +263,33 @@ def test_admonition_first_indented(self): ), extensions=['admonition'] ) + + def test_with_raw_html_block(self): + # A raw HTML block nested in an admonition should not be split into + # separate paragraphs at blank lines. See #1096. + self.assertMarkdownRenders( + self.dedent( + ''' + !!! note "Admonition" +
+ Some text + + Some more text +
+ ''' + ), + self.dedent( + ''' +
+

Admonition

+
+ Some text + + Some more text +
+ +
+ ''' + ), + extensions=['admonition'] + )