From b16a934a0445459e7eb1b2ab2577ed0be3440491 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 3 Nov 2026 17:43:45 -0700 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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}(?: after + + para + ''' + ), + self.dedent( + ''' +
    +
  • +

    item

    + +

    after

    +

    para

    +
  • +
+ ''' + ) + ) + + def test_processing_instruction_ended_blocks_later(self): + self.assertMarkdownRenders( + self.dedent( + ''' + - item + + + + para + ''' + ), + self.dedent( + ''' +
    +
  • +

    item

    + +

    para

    +
  • +
+ ''' + ) + ) + + def test_many_blocks_in_unterminated_comment(self): + """ + Each block held for an unterminated comment should only be searched for a terminator itself, as searching + everything held so far for each block takes quadratic time. + """ + count = 5000 + self.assertMarkdownRenders( + '- item\n\n + + - d + ''' + ), + self.dedent( + ''' +
    +
  • + +
  • +
  • +

    d

    +
  • +
+ ''' + ) + ) + + def test_definition(self): + """A raw block right after the colon of a definition.""" + + self.assertMarkdownRenders( + self.dedent( + ''' + Term + :
*x*
+ ''' + ), + self.dedent( + ''' +
+
Term
+
*x*
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_loose_definition(self): + self.assertMarkdownRenders( + self.dedent( + ''' + Term + + :
*x*
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+
*x*
+
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_definition_indent(self): + """The rest of a definition is already detabbed, so the indent is removed only once.""" + + self.assertMarkdownRenders( + self.dedent( + ''' + Term + :
+                    code
+                      more
+                    
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+                code
+                  more
+                
+
+ ''' + ), + extensions=['def_list'] + ) + + def test_md_in_html_tight_list_item(self): + """Parsed as it would be at the top of the document: a tight item's text has no `p`, but this does.""" + + self.assertMarkdownRenders( + self.dedent( + ''' + -
*x*
+ - two + ''' + ), + self.dedent( + ''' +
    +
  • +
    +

    x

    +
    +
  • +
  • two
  • +
+ ''' + ), + extensions=['md_in_html'] + ) + + def test_md_in_html_indented_content(self): + self.assertMarkdownRenders( + self.dedent( + ''' + -
+ *x* + + para +
+ + - two + ''' + ), + self.dedent( + ''' +
    +
  • +
    +

    x

    +

    para

    +
    +
  • +
  • +

    two

    +
  • +
+ ''' + ), + extensions=['md_in_html'] + ) + + def test_md_in_html_definition(self): + self.assertMarkdownRenders( + self.dedent( + ''' + Term + :
*x*
+ ''' + ), + self.dedent( + ''' +
+
Term
+
+
+

x

+
+
+
+ ''' + ), + extensions=['md_in_html', 'def_list'] + ) From b502d975eca0ad912393ff2e0931d563ed206014 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 14 Nov 2026 09:02:07 -0700 Subject: [PATCH 11/15] Strip whitespace before text on the line a raw block ends on Text after a raw block on the same line kept the whitespace before it. At the top of the document a paragraph strips that, but in a tight list item or definition the text is added to the item's text as it is, so the whitespace showed up in the output. Strip it from text on the line the raw block ends on. Text on a later line is left alone, as it may be an indented code block. Co-Authored-By: Claude Opus 5.5 --- markdown/blockprocessors.py | 6 ++++ .../blocks/test_indented_html_blocks.py | 28 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index 4f0e3bbbf..9e1d63459 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -302,6 +302,12 @@ def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: # Not something the extractor treats as raw HTML (a bogus comment, for example) blocks[0:0] = original return False + for i in range(1, len(result)): + if result[i][:1] in (' ', '\t') and util.HTML_PLACEHOLDER_RE.search(result[i - 1]): + # Text on the line a raw block ends on keeps the whitespace before it. At the top of the document + # its paragraph strips that, but the text of a tight list item is not a paragraph, so strip it here. + # Text on a later line (which starts with a newline) is left alone: it may be an indented code block. + result[i] = result[i].lstrip(' \t') blocks[0:0] = result return True diff --git a/tests/test_syntax/blocks/test_indented_html_blocks.py b/tests/test_syntax/blocks/test_indented_html_blocks.py index ba5215cb3..a9e53542f 100644 --- a/tests/test_syntax/blocks/test_indented_html_blocks.py +++ b/tests/test_syntax/blocks/test_indented_html_blocks.py @@ -3837,7 +3837,33 @@ def test_text_after_raw_block(self): '''
  • a
    - tail em
  • + tail em +
+ ''' + ) + ) + + def test_code_block_after_raw_block(self): + """Only text on the line the raw block ends on loses the whitespace before it: an indented line is code.""" + + self.assertMarkdownRenders( + self.dedent( + ''' + - a + +
a
+ code + ''' + ), + self.dedent( + ''' +
    +
  • +

    a

    +
    a
    +
    code
    +                
    +
''' ) From 230463492ae08f63acd8c6d52e19fa80f420007d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 14 Nov 2026 09:13:15 -0700 Subject: [PATCH 12/15] Document how raw HTML blocks nested in other blocks differ Describe under Differences how a raw HTML block is recognized at the top level of a document and, nested in a list item, definition, blockquote, admonition or footnote, the two ways it differs: it must start a block of its own rather than interrupt a paragraph, and an unclosed tag runs to the end of the nesting block rather than of the document. Move the changelog entry, which the merge of `master` left in the 3.11.3 release, to Unreleased under Changed, and describe what changes: Markdown is no longer parsed in such blocks, `md_in_html` now works in them, and they can not interrupt a paragraph. Add the new `finish` and `end_blocks` block processor methods under Added. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 26 ++++++++++++++++++++++---- docs/index.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b117607e9..81368a191 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,6 +14,28 @@ See the [Contributing Guide](contributing.md) for details. ## Unreleased +### Added + +* Add the `finish` and `end_blocks` methods to `BlockProcessor`, which the + block parser calls when its outermost call is done and when a nested call is + done. A block processor which holds on to blocks until a later call to the + parser must release them there. Content held for a nested block, such as an + unclosed raw HTML block in a list item, may be added to it after the call to + `parseChunk` which parsed it has returned. + +### Changed + +* Recognize raw HTML blocks nested in a list item, definition, blockquote, + admonition or footnote as at the top level of the document, rather than + parsing them as Markdown and splitting them into paragraphs at blank lines + (#1096). As at the top level, Markdown is no longer parsed within such a + block, unless the `md_in_html` extension is enabled and the element has a + `markdown` attribute, which is now supported in those places too. Unlike at + the top level, a nested raw HTML block must start a block of its own and + cannot interrupt a paragraph, and an unclosed tag runs to the end of the + nesting block rather than of the document. See + [Raw HTML Blocks](index.md#differences) for details. + ### Fixed * Fix for complex cases in new emphasis handling where text could be unexpectedly @@ -71,10 +93,6 @@ 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 diff --git a/docs/index.md b/docs/index.md index 7912d2361..357b7ab56 100644 --- a/docs/index.md +++ b/docs/index.md @@ -118,6 +118,35 @@ are summarized below: foreseeable future. That said, the [Sane List Extension](extensions/sane_lists.md) is available to provide a less surprising behavior. +* __Raw HTML Blocks__ + + The [syntax rules](https://daringfireball.net/projects/markdown/syntax#html) + state that a block-level HTML element must be separated from surrounding + content by blank lines, and that its start and end tags should not be + indented. At the top level of a document, Python-Markdown is more lenient: a + line which starts with a block-level tag, or with a comment, processing + instruction, `CDATA` section or declaration, indented by no more than three + spaces, starts a raw HTML block, even when it directly follows a line of a + paragraph. The block continues, across blank lines, until its tags are closed, + and a tag which is never closed runs to the end of the document. Markdown is + not parsed within a raw HTML block (the + [Markdown in HTML Extension](extensions/md_in_html.md) can change that). + + Nested in a list item, definition, blockquote, admonition or footnote, a raw + HTML block follows the same rules once the indentation of the nesting (or the + `>` of a blockquote) is removed, with two differences: + + 1. A raw HTML block must start a block of its own: after a blank line, right + after a list marker or the `:` of a definition, or on the first line of + the nested content. A block-level tag on a later line of a paragraph, + including a lazy continuation line, remains part of that paragraph. A + fenced code block is not recognized in nested content by the + [Fenced Code Blocks Extension](extensions/fenced_code_blocks.md), so + without this rule, the HTML of a code example in a list item would be + rendered rather than shown. + 2. A tag which is never closed runs to the end of the list item (or other + nesting block) rather than to the end of the document. + * __Automatic Links__ Automatic links recognize URL schemes followed by `://`, including schemes From b10e2d6027eceb99cd2c1a1b157ee471e8aaf93e Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 14 Nov 2026 09:16:19 -0700 Subject: [PATCH 13/15] Document md_in_html in nested blocks and the new block processor hooks Note on the `md_in_html` page that a raw HTML block nested in a list item, definition, blockquote, admonition or footnote may have a `markdown` attribute, and that its content is parsed as at the top level of the document, with an example. Describe in the extension API documentation when `end_blocks` and `finish` are called, what a block processor holding blocks across calls must release in them, and what a block processor which calls the parser can rely on: everything parsed into an element of its own is there when the call returns, but content held for an element at the end of the tree may be added after. List `RawHtmlBlockProcessor` as an example. Co-Authored-By: Claude Opus 5.5 --- docs/extensions/api.md | 22 ++++++++++++++++++++++ docs/extensions/md_in_html.md | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/extensions/api.md b/docs/extensions/api.md index 4822bbc2b..882554ffe 100644 --- a/docs/extensions/api.md +++ b/docs/extensions/api.md @@ -114,6 +114,27 @@ processor. There are three methods: For perspective, Markdown calls `parseDocument` which calls `parseChunk` which calls `parseBlocks` which calls your block processor, which, in turn, might call one of these routines. +#### Holding Blocks Across Calls + +A block which contains other blocks, such as a list item or an admonition, hands its content to the parser a block at a +time, in a call for each. A block processor which needs to see a later block before it knows what to do with an +earlier one may hold on to blocks across those calls, but must release them once no more are coming. The built-in +processor for raw HTML blocks nested in other blocks does this for a raw HTML block with a blank line in it. Two +methods of `BlockProcessor`, which do nothing unless overridden, are called for that: + +* [`end_blocks`][markdown.blockprocessors.BlockProcessor.end_blocks] is called with `parent` when a nested call to + the parser, which parsed blocks into `parent`, is done. The caller may do as it likes with `parent` once the call + returns, so anything held which no call still in progress can be counted on to hand more blocks to must be + released here. +* [`finish`][markdown.blockprocessors.BlockProcessor.finish] is called when the outermost call to the parser is done. + Anything still held must be released here. + +This affects a block processor which calls the parser itself. If it parses into an element of its own, which is not in +the tree being parsed (to move what is parsed somewhere else, for example), everything parsed is in that element by +the time the call returns. If it parses into an element at the end of the tree being parsed, which a later call may +hand more blocks to (as a list item is), what is held for that element may be added to it after the call returns: once +a later block closes it, or once the outermost call is done. + #### Example This example calls out important paragraphs by giving them a border. It looks for a fence line of exclamation points @@ -198,6 +219,7 @@ Some block processors in the Markdown source tree include: | [`HashHeaderProcessor`][markdown.blockprocessors.HashHeaderProcessor] | built-in | Title hashes (`#`), which may split blocks | | [`HRProcessor`][markdown.blockprocessors.HRProcessor] | built-in | Horizontal lines, e.g., `---` | | [`OListProcessor`][markdown.blockprocessors.OListProcessor] | built-in | Ordered lists; complex and using `state` | +| [`RawHtmlBlockProcessor`][markdown.blockprocessors.RawHtmlBlockProcessor] | built-in | Nested raw HTML; holds blocks across calls | | [`AdmonitionProcessor`][markdown.extensions.admonition.AdmonitionProcessor] | extension | Render each [Admonition][] in a new `div` | | [`FootnoteBlockProcessor`][markdown.extensions.footnotes.FootnoteBlockProcessor] | extension | Find footnote definitions and store for later use. | diff --git a/docs/extensions/md_in_html.md b/docs/extensions/md_in_html.md index 13a9a88b3..224ab70cd 100644 --- a/docs/extensions/md_in_html.md +++ b/docs/extensions/md_in_html.md @@ -170,6 +170,27 @@ behavior. However, if the value of an element's `markdown` attribute is the same parent, the the child element's behavior is observed. For example, a `block` element may contain either `block` elements or `span` elements as children and each element will be parsed using the specified behavior. +### In Lists and Other Blocks + +A raw HTML block nested in a list item, definition, blockquote, admonition or footnote may have a `markdown` +attribute as well. Its content is parsed as it would be at the top level of the document, whatever block it is nested +in. For example, a paragraph within it is wrapped in a `

` tag even in a tight list item, whose own text is not. + +``` md-render +--- +extensions: [md_in_html] +--- +*

This is a *Markdown* paragraph.
+* A tight list item. +* Another list item. + +
+ This is a *Markdown* paragraph too. +
+``` + +See [Raw HTML Blocks](../index.md#differences) for where a raw HTML block is recognized in nested content. + ### Tag Normalization While the default behavior is for Markdown to not alter raw HTML, as this extension is parsing the content of raw HTML elements, it will do some normalization of the tags of block-level elements. For example: From 3f6e5d53d70b9806eed3b0dafd675c6c04dac7d9 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 14 Nov 2026 09:19:16 -0700 Subject: [PATCH 14/15] Fix lint --- tests/test_syntax/blocks/test_indented_html_blocks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_syntax/blocks/test_indented_html_blocks.py b/tests/test_syntax/blocks/test_indented_html_blocks.py index a9e53542f..3bbcc6ad5 100644 --- a/tests/test_syntax/blocks/test_indented_html_blocks.py +++ b/tests/test_syntax/blocks/test_indented_html_blocks.py @@ -3735,6 +3735,7 @@ def test_many_blocks_in_unterminated_comment(self): '
    \n
  • \n

    item

    \n

    <!--

    \n' + '

    para

    \n' * count + '
  • \n
' ) + class TestListItemFirstBlock(TestCase): """ A raw block right after the marker of a list item, or the colon of a definition, is extracted like one in any From 290830bef580bf13ee77ef1f4836828f520c8131 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 14 Nov 2026 09:26:02 -0700 Subject: [PATCH 15/15] Document that an override of parseBlocks must call the parent's `BlockParser.parseBlocks` calls the `end_blocks` and `finish` methods of block processors. A subclass which overrides it without calling the parent class's method never does, so blocks a processor holds, such as an unclosed raw HTML block in a list item, are never released and their content is lost. Say so in the extension API documentation and in the docstring of `parseBlocks`. Co-Authored-By: Claude Opus 5.5 --- docs/extensions/api.md | 6 ++++++ markdown/blockparser.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/docs/extensions/api.md b/docs/extensions/api.md index 882554ffe..3fc2c91a6 100644 --- a/docs/extensions/api.md +++ b/docs/extensions/api.md @@ -135,6 +135,12 @@ the time the call returns. If it parses into an element at the end of the tree b hand more blocks to (as a list item is), what is held for that element may be added to it after the call returns: once a later block closes it, or once the outermost call is done. +!!! warning + + The parser calls these methods from [`parseBlocks`][markdown.blockparser.BlockParser.parseBlocks]. A subclass of + `BlockParser` which overrides `parseBlocks` must call the parent class's `parseBlocks` to parse the blocks. + Otherwise, blocks held by a block processor are never released, and their content is lost from the output. + #### Example This example calls out important paragraphs by giving them a border. It looks for a fence line of exclamation points diff --git a/markdown/blockparser.py b/markdown/blockparser.py index 4d3ebfe03..60a1c621d 100644 --- a/markdown/blockparser.py +++ b/markdown/blockparser.py @@ -150,6 +150,13 @@ def parseBlocks(self, parent: etree.Element, blocks: list[str]) -> None: additional `BlockProcessors` which call this method to recursively parse a nested block. + Once the blocks are parsed, this calls each processor's + [`end_blocks`][markdown.blockprocessors.BlockProcessor.end_blocks] (for a + nested call) or [`finish`][markdown.blockprocessors.BlockProcessor.finish] + (for the outermost call), so that a processor can release blocks it holds. + A subclass which overrides this method must call it to parse the blocks; + otherwise, held blocks are never released and their content is lost. + Arguments: parent: The parent element. blocks: The blocks of text to parse.