From b16a934a0445459e7eb1b2ab2577ed0be3440491 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 3 Nov 2026 17:43:45 -0700 Subject: [PATCH 1/5] Extract raw HTML blocks nested in containers in a block processor The HTML preprocessor can only extract a raw block that starts at the beginning of a line. Within a container (a list item, an admonition, etc.) the block is indented, which cannot be told apart from an indented code block, so it was left as text. The block parser then treated it as inline HTML and split it at any blank lines. Once the container has removed its indentation, a block that starts with a block level tag can only be a raw block. A new `RawHtmlBlockProcessor` extracts it there, using the same extractor as the preprocessor (or the `md_in_html` extractor when that extension is enabled). Containers pass their content on a block at a time, so a raw block with blank lines in it is collected across calls, keyed by the container, until it is closed. `RawHtmlFlushTreeprocessor` finishes anything still open when the document has been parsed. The preprocessor is not changed, so extensions that work on the text of the document before it is parsed see exactly what they saw before, and indented code blocks, paragraph continuation lines and list items render as they always have. Content of a blockquote is left alone to keep its current behavior. Co-Authored-By: Claude Sonnet 5.5 --- markdown/blockprocessors.py | 81 + markdown/extensions/md_in_html.py | 1 + markdown/treeprocessors.py | 11 + .../blocks/test_indented_html_blocks.py | 1967 +++++++++++++++++ 4 files changed, 2060 insertions(+) create mode 100644 tests/test_syntax/blocks/test_indented_html_blocks.py diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index d3574045f..ffba28006 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -43,6 +43,7 @@ def build_block_parser(md: Markdown, **kwargs: Any) -> BlockParser: """ Build the default block parser used by Markdown. """ parser = BlockParser(md) parser.blockprocessors.register(EmptyBlockProcessor(parser), 'empty', 100) + parser.blockprocessors.register(RawHtmlBlockProcessor(parser), 'rawhtml', 95) parser.blockprocessors.register(ListIndentProcessor(parser), 'indent', 90) parser.blockprocessors.register(CodeBlockProcessor(parser), 'code', 80) parser.blockprocessors.register(HashHeaderProcessor(parser), 'hashheader', 70) @@ -150,6 +151,86 @@ def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: pass # pragma: no cover +class RawHtmlBlockProcessor(BlockProcessor): + """ + Extract raw HTML blocks nested in a container. + + The preprocessor can only extract a raw block that is at the start of a line. Within a container (a list item, + an admonition, etc.) the block is indented, which can not be told apart from an indented code block until the + container has removed its indentation. At that point the block starts a block of text and is extracted here. + + Containers pass their content on a block at a time, so a raw block with blank lines in it is collected across + calls until it is closed. Anything still open when the document has been parsed is handled by + [`RawHtmlFlushTreeprocessor`][markdown.treeprocessors.RawHtmlFlushTreeprocessor]. + """ + + START_RE = re.compile(r'^[ ]{0,3}(?: + + after + ''' + ), + self.dedent( + ''' +
+

Title

+ +

after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_admonition_unclosed_div(self): + """ + Unclosed raw block at end of document (flush in close()). Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +
+ **test** + ''' + ), + self.dedent( + ''' +
+

Title

+
+ **test** +
+ ''' + ), + extensions=['admonition'] + ) + + def test_paragraph_continuation_div(self): + """ + An indented line directly after paragraph text is a paragraph continuation, not code. Regression: + renders as it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
x
+ ''' + ), + self.dedent( + ''' +

some text +

x

+ ''' + ) + ) + + def test_paragraph_continuation_div_multiline(self): + """Same, multi-line raw block. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
+ **x** +
+ more text + ''' + ), + self.dedent( + ''' +

some text +

+ x +
+ more text

+ ''' + ) + ) + + def test_paragraph_chain_with_text(self): + """ + Blocks that start where the last one ended, mid-paragraph, then text. Must stay one inline run. + Regression: renders as it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
a
b
trailing + ''' + ), + self.dedent( + ''' +

some text +

a
b
trailing

+ ''' + ) + ) + + def test_paragraph_chain_with_more_lines(self): + """ + Same with an indented continuation line and a following paragraph. Regression: renders as it always + has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
a
b
trailing + more + + after + ''' + ), + self.dedent( + ''' +

some text +

a
b
trailing + more

+

after

+ ''' + ) + ) + + def test_paragraph_chain_of_three(self): + """Three blocks in a row. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
a
b
c
+ ''' + ), + self.dedent( + ''' +

some text +

a
b
c

+ ''' + ) + ) + + def test_paragraph_chain_multiline(self): + """ Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
+ a +
+ b +
trailing + more + ''' + ), + self.dedent( + ''' +

some text +

+ a +
+ b +
trailing + more

+ ''' + ) + ) + + def test_paragraph_chain_multiline_indented(self): + """Same with the continuation line indented. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
+ a +
+ b +
trailing + more + ''' + ), + self.dedent( + ''' +

some text +

+ a +
+ b +
trailing + more

+ ''' + ) + ) + + def test_code_chain_blocks(self): + """ Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + text + +
a
b
c
trailing + more + ''' + ), + self.dedent( + ''' +

text

+
<div>a</div><div>b</div><div>c</div> trailing
+                more
+                
+ ''' + ) + ) + + def test_code_chain_multiline(self): + """Same, multi-line blocks. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + text + +
+ a +
+ b +
trailing + more + ''' + ), + self.dedent( + ''' +

text

+
<div>
+                a
+                </div><div>
+                b
+                </div> trailing
+                more
+                
+ ''' + ) + ) + + def test_list_lazy_chain(self): + """ Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - item +
a
b
trailing + ''' + ), + self.dedent( + ''' +
    +
  • item +
    a
    b
    trailing
  • +
+ ''' + ) + ) + + def test_list_lazy_div_next_item(self): + """ + Indented div on a lazy line of a list item, followed by another item. Both items must stay tight (no +

): nothing may be inserted between the placeholder and the next item. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + - item +

x
+ - b + ''' + ), + self.dedent( + ''' +
    +
  • item +
    x
  • +
  • b
  • +
+ ''' + ) + ) + + def test_list_lazy_tail_next_item(self): + """ + Same with text after the closing tag. The next item must not be swallowed into the paragraph. + Regression: renders as it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + - item +
x
trailing + - b + ''' + ), + self.dedent( + ''' +
    +
  • item +
    x
    trailing
  • +
  • b
  • +
+ ''' + ) + ) + + def test_list_lazy_tail_next_item_ordered(self): + """Same in an ordered list. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + 1. item +
x
trailing + 2. b + ''' + ), + self.dedent( + ''' +
    +
  1. item +
    x
    trailing
  2. +
  3. b
  4. +
+ ''' + ) + ) + + def test_paragraph_tail_then_code(self): + """ + Text after the closing tag in a paragraph, then a blank line and an indented code block. The code + block must stay a code block. Regression: renders as it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + some text +
x
trailing + + code + ''' + ), + self.dedent( + ''' +

some text +

x
trailing

+
code
+                
+ ''' + ) + ) + + def test_fn_div_blank_lines(self): + """Raw HTML with blank lines in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: +
+ + **x** + +
+ + after + ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '
    \n' + '\n' + '**x**\n' + '\n' + '
    \n' + '

    after ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_fn_text_then_div(self): + """Raw HTML after the text of a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: Note text. + +
+ **x** +
+ + after + ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '

    Note text.

    \n' + '
    \n' + '**x**\n' + '
    \n' + '

    after ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_fn_lazy_div(self): + """ + Indented HTML on a lazy line of a footnote is part of the paragraph. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: Note text. +
x
+ ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '

    Note text.\n' + '

    x
     ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_fn_code_with_html(self): + """Indented code with HTML in a footnote. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: Note. + +
+ + code +
+ ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '

    Note.

    \n' + '
    <div>\n'
    +            '\n'
    +            'code\n'
    +            '</div>\n'
    +            '
    \n' + '

    ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_fn_mdhtml(self): + """A `markdown` element with blank lines in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: +
+ + **x** + +
+ ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '
    \n' + '

    x

    \n' + '
    \n' + '

    ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes', 'md_in_html'] + ) + + def test_fn_unclosed(self): + """An unclosed raw block in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: +
+ + **x** + ''' + ), + self.dedent( + ''' +

Text1

+
+
+
    +
  1. +
+
+ ''' + ), + extensions=['footnotes'] + ) + + def test_dl_div_blank_lines(self): + """Raw HTML with blank lines in a definition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : para + +
+ + **x** + +
+ + after + ''' + ), + self.dedent( + ''' +
+
Term
+
+

para

+
+ + **x** + +
+

after

+
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_dl_div_after_definition(self): + """Raw HTML after the text of a definition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : definition + +
+ **x** +
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+

definition

+
+ **x** +
+
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_dl_lazy_div(self): + """ + Indented HTML on a lazy line of a definition is part of the paragraph. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : definition +
x
+ ''' + ), + self.dedent( + ''' +
+
Term
+
definition +
x
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_dl_code_with_html(self): + """Indented code with HTML in a definition. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : definition + +
+ + code +
+ ''' + ), + self.dedent( + ''' +
+
Term
+
definition
<div>
+
+                code
+                </div>
+                
+
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_dl_mdhtml(self): + """A `markdown` element with blank lines in a definition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : para + +
+ + **x** + +
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+

para

+
+

x

+
+
+
+ ''' + ), + extensions=['def_list', 'md_in_html'] + ) + + def test_dl_unclosed(self): + """An unclosed raw block in a definition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : para + +
+ + **x** + ''' + ), + self.dedent( + ''' +
+
Term
+
+

para

+
+ + **x** +
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_adm_in_footnote(self): + """Raw HTML in an admonition in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Text[^1] + + [^1]: + !!! note +
+ + **x** + +
+ ''' + ), + '

Text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '
    \n' + '

    Note

    \n' + '
    \n' + '\n' + '**x**\n' + '\n' + '
    \n' + '
    \n' + '

    ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes', 'admonition'] + ) + + def test_list_in_admonition(self): + """Raw HTML with blank lines in a list in an admonition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + - item + +
+ + **x** + +
+ + after + ''' + ), + self.dedent( + ''' +
+

Note

+
    +
  • +

    item

    +
    + + **x** + +
    +

    after

    +
  • +
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_admonition_in_list_in_definition(self): + """Three levels of containers. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : - item + + !!! note +
+ + **x** + +
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+
    +
  • +

    item

    +
    +

    Note

    +
    + + **x** + +
    +
    +
  • +
+
+
+ ''' + ), + extensions=['def_list', 'admonition'] + ) + + def test_admonition_in_blockquote(self): + """ + An admonition in a blockquote is a container, so its indented HTML is raw. Intended change: a raw + block in a container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + > !!! note + >
+ > **x** + >
+ ''' + ), + self.dedent( + ''' +
+
+

Note

+
+ **x** +
+
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_list_lazy_continuation_div(self): + """Indented div right after a list item line (no blank line). Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - item +
x
+ ''' + ), + self.dedent( + ''' +
    +
  • item +
    x
  • +
+ ''' + ) + ) + + def test_code_block_in_list(self): + """ + Code block (8 spaces) inside a list item containing HTML with blank lines. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + - item + +
+ + code +
+ ''' + ), + self.dedent( + ''' +
    +
  • item
    <div>
    +
    +                code
    +                </div>
    +                
    +
  • +
+ ''' + ) + ) + + def test_code_block_in_admonition(self): + """Code block (8 spaces) inside an admonition containing HTML. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +
+ code +
+ ''' + ), + self.dedent( + ''' +
+

Title

+
<div>
+                code
+                </div>
+                
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_after_close(self): + """ + Text on the same line after the closing tag. Text after a block starts its own paragraph (as at the + root). Intended change: a raw block in a container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +
x
trailing + + after + ''' + ), + self.dedent( + ''' +
+

Title

+
x
+

trailing

+

after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_multiline_block(self): + """ + Multi-line block followed by text on the closing line, then a lazy continuation line. Intended + change: a raw block in a container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +
+ **x** +
trailing + more + + after + ''' + ), + self.dedent( + ''' +
+

Title

+
+ **x** +
+

trailing + more

+

after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_chained_blocks(self): + """ + A second block starts on the closing line of the first, followed by text. Intended change: a raw + block in a container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +
a
b
trailing + + after + ''' + ), + self.dedent( + ''' +
+

Title

+
a
+
b
+

trailing

+

after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_after_comment(self): + """ + Text after an indented comment on the same line (empty-tag path). Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" + trailing + + after + ''' + ), + self.dedent( + ''' +
+

Title

+ +

trailing

+

after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_in_list(self): + """Text after the closing tag inside a list item. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - item + +
x
trailing + + after + ''' + ), + self.dedent( + ''' +
    +
  • +

    item

    +
    x
    +

    trailing

    +

    after

    +
  • +
+ ''' + ) + ) + + def test_trailing_text_in_code_block(self): + """ + Indented CODE with HTML followed by text on the same line must stay one code line. Regression: + renders as it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + text + +
x
trailing + more code + ''' + ), + self.dedent( + ''' +

text

+
<div>x</div> trailing
+                more code
+                
+ ''' + ) + ) + + def test_trailing_text_in_code_block_in_admonition(self): + """Same inside an admonition (8 spaces). Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Title" +

x

trailing + more code + ''' + ), + self.dedent( + ''' +
+

Title

+
<p>x</p> trailing
+                more code
+                
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_trailing_text_nested_admonition(self): + """Two container levels. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note "Outer" + !!! note "Inner" +
x
trailing + + after + ''' + ), + self.dedent( + ''' +
+

Outer

+
+

Inner

+
x
+

trailing

+

after

+
+
+ ''' + ), + extensions=['admonition'] + ) From 09bbb187a94083d62e6d20374980692f21e39cf2 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 3 Nov 2026 19:59:28 -0700 Subject: [PATCH 2/5] Handle elements under blockquotes and fix comments --- markdown/blockparser.py | 20 +- markdown/blockprocessors.py | 90 +- markdown/treeprocessors.py | 11 - .../blocks/test_indented_html_blocks.py | 1477 ++++++++++++++++- 4 files changed, 1560 insertions(+), 38 deletions(-) diff --git a/markdown/blockparser.py b/markdown/blockparser.py index 549c9ecd5..ce54b4221 100644 --- a/markdown/blockparser.py +++ b/markdown/blockparser.py @@ -96,6 +96,7 @@ def __init__(self, md: Markdown): self.blockprocessors: util.Registry[BlockProcessor] = util.Registry() self.state = State() self.md = md + self._depth = 0 def parseDocument(self, lines: Iterable[str]) -> etree.ElementTree: """ Parse a Markdown document into an `ElementTree`. @@ -113,6 +114,7 @@ def parseDocument(self, lines: Iterable[str]) -> etree.ElementTree: An element tree. """ # Create an `ElementTree` from the lines + self._depth = 0 self.root = etree.Element(self.md.doc_tag) self.parseChunk(self.root, '\n'.join(lines)) return etree.ElementTree(self.root) @@ -152,9 +154,17 @@ def parseBlocks(self, parent: etree.Element, blocks: list[str]) -> None: blocks: The blocks of text to parse. """ - while blocks: + self._depth += 1 + try: + while blocks: + for processor in self.blockprocessors: + if processor.test(parent, blocks[0]): + if processor.run(parent, blocks) is not False: + # run returns True or None + break + finally: + self._depth -= 1 + if not self._depth: + # The outermost call is done, so nothing more will be handed to a processor for these parents. for processor in self.blockprocessors: - if processor.test(parent, blocks[0]): - if processor.run(parent, blocks) is not False: - # run returns True or None - break + processor.finish() diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index ffba28006..e19116c70 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -150,26 +150,49 @@ def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: """ pass # pragma: no cover + def finish(self) -> None: + """ Called when the outermost call to the parser is done. Does nothing unless overridden. + + A processor which holds on to blocks, to decide what to do with them once it has seen the next, must + release them here: this is the last the parser will hand it for the parents involved. + """ + pass + + +class HeldBlocks(list): + """ Blocks which are held back until it is known whether they are raw HTML, and the parser state they are in. """ + + def __init__(self, blocks: list[str], state: list): + super().__init__(blocks) + self.state = list(state) + class RawHtmlBlockProcessor(BlockProcessor): """ Extract raw HTML blocks nested in a container. The preprocessor can only extract a raw block that is at the start of a line. Within a container (a list item, - an admonition, etc.) the block is indented, which can not be told apart from an indented code block until the - container has removed its indentation. At that point the block starts a block of text and is extracted here. + an admonition, a blockquote, etc.) the block is indented or quoted, which can not be told apart from an indented + code block or a paragraph until the container has removed its indentation or markers. At that point the block + starts a block of text and is extracted here. Containers pass their content on a block at a time, so a raw block with blank lines in it is collected across - calls until it is closed. Anything still open when the document has been parsed is handled by - [`RawHtmlFlushTreeprocessor`][markdown.treeprocessors.RawHtmlFlushTreeprocessor]. + calls until it is closed. Anything still open when the parser is done is handled in + [`finish`][markdown.blockprocessors.RawHtmlBlockProcessor.finish]. """ START_RE = re.compile(r'^[ ]{0,3}(?: + > text + ''' + ), + self.dedent( + ''' +
+ +

text

+
+ ''' + ) + ) + + def test_blockquote_pre_blank_line(self): + """A blank line in a raw block does not split it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >
+                > a
+                >
+                > b
+                > 
+ ''' + ), + self.dedent( + ''' +
+
+                a
+
+                b
+                
+
+ ''' + ) + ) + + def test_blockquote_script(self): + """The content of a raw block is not escaped. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > + ''' + ), + self.dedent( + ''' +
+ +
+ ''' + ) + ) + + def test_blockquote_table(self): + """A table in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > + > + >
x
+ ''' + ), + self.dedent( + ''' +
+ + +
x
+
+ ''' + ) + ) + + def test_blockquote_md_in_html(self): + """A `markdown` attribute works in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >
+ > **x** + >
+ ''' + ), + self.dedent( + ''' +
+
+

x

+
+
+ ''' + ), + extensions=['md_in_html'] + ) + + def test_blockquote_md_in_html_block(self): + """A `markdown` attribute with blank lines. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >
+ > + > **x** + > + >
+ ''' + ), + self.dedent( + ''' +
+
+

x

+
+
+ ''' + ), + extensions=['md_in_html'] + ) + + def test_blockquote_in_footnote(self): + """Raw block in a blockquote in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + text[^1] + + [^1]: + >
+ > x + >
+ ''' + ), + '

text1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '
    \n' + '
    \n' + 'x\n' + '
    \n' + '
    \n' + '

    ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_blockquote_paragraph_continuation(self): + """A raw block can not interrupt a paragraph in a blockquote. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > a + >
x
+ ''' + ), + self.dedent( + ''' +
+

a +

x

+
+ ''' + ) + ) + + def test_blockquote_inline_tag(self): + """An inline tag in a blockquote is not a raw block. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > x text + ''' + ), + self.dedent( + ''' +
+

x text

+
+ ''' + ) + ) + + def test_blockquote_code_block(self): + """An indented code block in a blockquote is code. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >
+ > x + >
+ ''' + ), + self.dedent( + ''' +
+
<div>
+                x
+                </div>
+                
+
+ ''' + ) + ) + + def test_blockquote_fenced_code(self): + """HTML in a fenced code block in a blockquote is code. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > ``` + >
+ > ``` + ''' + ), + self.dedent( + ''' +
+

<div>

+
+ ''' + ), + extensions=['fenced_code'] + ) + + def test_blockquote_list_marker_html(self): + """ + What follows a list marker in a blockquote is parsed as it always has been. Regression: renders as + it always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + > -
x
+ ''' + ), + self.dedent( + ''' +
+
    +
  • x
  • +
+
+ ''' + ) + ) + + def test_blockquote_invalid_declaration(self): + """ + Something the extractor does not treat as a raw block is left alone. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + > + > text + ''' + ), + self.dedent( + ''' +
+

<!invalid> + text

+
+ ''' + ) + ) + + def test_comment_blank_line_admonition(self): + """A comment with a blank line in it is one raw block. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + text + ''' + ), + self.dedent( + ''' +
+

Note

+ +

text

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_list(self): + """A comment with a blank line in a list item. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - a + + + text + ''' + ), + self.dedent( + ''' +
    +
  • +

    a

    + +

    text

    +
  • +
+ ''' + ) + ) + + def test_comment_blank_line_definition(self): + """A comment with a blank line in a definition. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + Term + : a + + + text + ''' + ), + self.dedent( + ''' +
+
Term
+
+

a

+ +

text

+
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_comment_blank_line_blockquote(self): + """A comment with a blank line in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > + > text + ''' + ), + self.dedent( + ''' +
+ +

text

+
+ ''' + ) + ) + + def test_comment_blank_line_blockquote_in_list(self): + """ + A comment with a blank line in a blockquote in a list item. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + - a + + > + > text + ''' + ), + self.dedent( + ''' +
    +
  • +

    a

    +
    + +

    text

    +
    +
  • +
+ ''' + ) + ) + + def test_comment_blank_line_footnote(self): + """A comment with a blank line in a footnote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + x[^1] + + [^1]: + + text + ''' + ), + '

x1

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '\n' + '

    text ↩

    \n' + '
  2. \n' + '
\n' + '
', + extensions=['footnotes'] + ) + + def test_comment_many_blank_lines(self): + """A comment with several blank lines in it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + text + ''' + ), + self.dedent( + ''' +
+

Note

+ +

text

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_then_div(self): + """A raw block follows a comment with a blank line. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + +
x
+ ''' + ), + self.dedent( + ''' +
+

Note

+ +
x
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_two_comments(self): + """Two comments with blank lines in them. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + + + ''' + ), + self.dedent( + ''' +
+

Note

+ + +
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_after_paragraph(self): + """A comment with a blank line after a paragraph. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + para + + + ''' + ), + self.dedent( + ''' +
+

Note

+

para

+ +
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_div_after_close(self): + """A raw block starts on the line where the comment ends. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note +
x
+ ''' + ), + self.dedent( + ''' +
+

Note

+ +
x
+
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_blank_line_md_in_html(self): + """ + A `markdown` attribute after a comment with a blank line. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + +
+ **x** +
+ ''' + ), + self.dedent( + ''' +
+

Note

+ +
+

x

+
+
+ ''' + ), + extensions=['admonition', 'md_in_html'] + ) + + def test_processing_instruction_blank_line(self): + """A processing instruction with a blank line in it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + ''' + ), + self.dedent( + ''' +
+

Note

+ +
+ ''' + ), + extensions=['admonition'] + ) + + def test_processing_instruction_blank_line_blockquote(self): + """ + A processing instruction with a blank line in a blockquote. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + > c + > + > ?> + ''' + ), + self.dedent( + ''' +
+ +
+ ''' + ) + ) + + def test_cdata_blank_line(self): + """A CDATA section with a blank line in it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + ''' + ), + self.dedent( + ''' +
+

Note

+ +
+ ''' + ), + extensions=['admonition'] + ) + + def test_declaration_blank_line(self): + """A declaration with a blank line in it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + ''' + ), + self.dedent( + ''' +
+

Note

+ +
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_unclosed_admonition(self): + """A comment which is never closed is text. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + + +

<!-- b

+

text

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_comment_in_text_line(self): + """A comment that does not start a block is text. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + text more + ''' + ), + self.dedent( + ''' +
+

Note

+

text <!-- + c

+

--> more

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_processing_instruction_unclosed(self): + """A processing instruction which is never closed is text. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + +

Note

+

<?php + c

+

text

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_cdata_unclosed(self): + """A CDATA section which is never closed is text. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + +

Note

+

<![CDATA[ + c

+

text

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_cdata_spaced_close(self): + """A CDATA section is closed by `]]>` only. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + + after + ''' + ), + self.dedent( + ''' +
+

Note

+

<![CDATA[ + c

+

] ]> + after

+
+ ''' + ), + extensions=['admonition'] + ) + + def test_declaration_unclosed(self): + """A declaration which is never closed is text. Regression: renders as it always has.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + +

Note

+

<!DOCTYPE + c

+

text

+
+ ''' + ), + extensions=['admonition'] + ) + def test_list_lazy_continuation_div(self): """Indented div right after a list item line (no blank line). Regression: renders as it always has.""" From 0532a4e96c16c02e6212df4695b13362b6e6ac52 Mon Sep 17 00:00:00 2001 From: inchang-ing <197932532+inchang-ing@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:46:22 +0800 Subject: [PATCH 3/5] Add a changelog entry for indented raw HTML blocks Fixes #1096 --- docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index f3335f1e5..3d7734939 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -41,6 +41,10 @@ See the [Contributing Guide](contributing.md) for details. provided, rather than rendering them as `key="key"`, so the same source renders consistently under both the `html` and `xhtml` output formats (#1501). +* Treat a block-level raw HTML tag indented by more than three spaces as a raw + HTML block when it is nested in a containing block (a list item, an + admonition, etc.), instead of splitting it into paragraphs at blank lines. + Within an indented code block such HTML is still rendered as code (#1096). ## [3.11.2] - 2026-10-19 From 17029a99f40ae5dd82648de015bf4dd088e62524 Mon Sep 17 00:00:00 2001 From: inchang-ing <197932532+inchang-ing@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:56:57 +0800 Subject: [PATCH 4/5] Add CDATA to the spell dictionary The two new CDATA tests describe the section by name in their docstrings, and checkspelling scans test docstrings, so it failed on that word in both. --- .spell-dict | 1 + 1 file changed, 1 insertion(+) diff --git a/.spell-dict b/.spell-dict index f70d2bc8d..6465a7604 100644 --- a/.spell-dict +++ b/.spell-dict @@ -18,6 +18,7 @@ blockquote blockquotes boolean CamelCase +CDATA Chodarev changelog changelogs From b31f189e55639b8bbfa07f600ff3722a3a9abd1f Mon Sep 17 00:00:00 2001 From: inchang-ing <197932532+inchang-ing@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:01:23 +0800 Subject: [PATCH 5/5] Use code spans for code terms instead of adding them to the spell dictionary The spelling check flagged etree and IndentedRawHtml in a new test docstring. Following the review suggestion, wrap code terms in code spans (which are not spellchecked) in the new comments and docstrings, and drop the CDATA dictionary entry added earlier so the dictionary matches upstream again. Code spans also render correctly in the generated API documentation. --- .spell-dict | 1 - markdown/blockprocessors.py | 4 ++-- tests/test_syntax/blocks/test_indented_html_blocks.py | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.spell-dict b/.spell-dict index 6465a7604..f70d2bc8d 100644 --- a/.spell-dict +++ b/.spell-dict @@ -18,7 +18,6 @@ blockquote blockquotes boolean CamelCase -CDATA Chodarev changelog changelogs diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index e19116c70..a503b3338 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -182,7 +182,7 @@ class RawHtmlBlockProcessor(BlockProcessor): """ START_RE = re.compile(r'^[ ]{0,3}(?: