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 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 d3574045f..a503b3338 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) @@ -149,6 +150,154 @@ 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, 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 parser is done is handled in + [`finish`][markdown.blockprocessors.RawHtmlBlockProcessor.finish]. + """ + + START_RE = re.compile(r'^[ ]{0,3}(?: + + after + ''' + ), + self.dedent( + ''' +
Title
+ +after
+Title
+some text +
some text +
some text +
some text +
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 +some text +
some text +
some text +
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
+
+ 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
+ ): nothing may be inserted between the placeholder and the next item. Regression: renders as it + always has. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + - item +
some text +
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]:
+ Text1
\n' + 'after ↩
\n' + 'Text1
\n' + 'Note text.
\n' + 'after ↩
\n' + 'Text1
\n' + 'Note text.\n' + '
Text1
\n' + 'Note.
\n' + '<div>\n'
+ '\n'
+ 'code\n'
+ '</div>\n'
+ '\n'
+ '\n'
+ 'Text1
\n' + 'x
\n' + 'Text1
\n' + 'para
+after
+definition
+<div>
+
+ code
+ </div>
+
+ para
+x
+para
+Text1
\n' + 'Note
\n' + 'Note
+item
+after
+item
+Note
+++ ''' + ), + extensions=['admonition'] + ) + + def test_blockquote_in_admonition(self): + """A blockquote is a container too, so HTML in one is raw. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + !!! note + >++Note
++ **x** ++
Note
++++ **x** ++
++ ''' + ) + ) + + def test_blockquote_div_blank_lines(self): + """Blank lines in the raw block do not end it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >+ **x** ++
++ ''' + ) + ) + + def test_blockquote_div_after_paragraph(self): + """Raw block after a paragraph in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > para + > + >+ + **x** + ++
++ ''' + ) + ) + + def test_blockquote_div_then_paragraph(self): + """A paragraph follows the raw block. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >para
++ **x** ++
++ ''' + ) + ) + + def test_blockquote_text_after_close(self): + """ + Text after the end of the raw block starts its own paragraph. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + >+ x ++after
+
++ ''' + ) + ) + + def test_blockquote_two_blocks(self): + """Two raw blocks in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >x+more
+
++ ''' + ) + ) + + def test_blockquote_chained_blocks(self): + """Raw blocks that start where the last one ended. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >a+b+
++ ''' + ) + ) + + def test_blockquote_lazy_line_in_html(self): + """A lazy line inside the raw block belongs to it. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >a+b+tail
+
++ ''' + ) + ) + + def test_blockquote_lazy_line_after_html(self): + """ + A lazy line after the raw block is a paragraph in the blockquote. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + >+ lazy ++
++ ''' + ) + ) + + def test_blockquote_unclosed_html(self): + """ + A raw block that is never closed ends with the blockquote. Intended change: a raw block in a + container. + """ + + self.assertMarkdownRenders( + self.dedent( + R''' + >x+lazy
+
++ x + ++ ''' + ), + 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 + +after
+ ''' + ) + ) + + def test_blockquote_nested(self): + """Raw block in a nested blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > >+ > > **x** + > >+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + def test_blockquote_nested_after_text(self): + """Raw block in a blockquote nested after text. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > text + > >+++ **x** +++ > > x + > >+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + def test_blockquote_in_list(self): + """Raw block in a blockquote in a list item. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - item + + >text
++++ x +++ > **x** + >+ ''' + ), + self.dedent( + ''' ++
+ ''' + ) + ) + + def test_blockquote_on_list_marker(self): + """Raw block in a blockquote on the line of a list marker. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + - >- +
+item
++++ **x** +++ > **x** + >+ ''' + ), + self.dedent( + ''' ++
+ ''' + ) + ) + + def test_blockquote_comment(self): + """A comment in a blockquote is raw. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + > + > text + ''' + ), + self.dedent( + ''' +- +
++++ **x** +++ ++ ''' + ) + ) + + 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''' + >text
++ > a + > + > b + >+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + 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( + ''' ++ a + + b +++ ++ ''' + ) + ) + + def test_blockquote_table(self): + """A table in a blockquote. Intended change: a raw block in a container.""" + + self.assertMarkdownRenders( + self.dedent( + R''' + >+ >
+ ''' + ), + 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 + > **x** + >+ ''' + ), + self.dedent( + ''' +++ ''' + ), + 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
++ > + > **x** + > + >+ ''' + ), + self.dedent( + ''' +++ ''' + ), + 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
++ > x + >+ ''' + ), + 'text1
\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 + >
\n' + '\n' + '
\n' + '- \n' + '
\n' + '\n' + '\n' + '\n' + '\n' + 'x\n' + '\n' + 'x+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + 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( + ''' +a +
x+++ ''' + ) + ) + + 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 text
++ > x + >+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + 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''' + > ``` + >+<div> + x + </div> ++ > ``` + ''' + ), + self.dedent( + ''' ++ ''' + ), + 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( + ''' +++ ''' + ), + 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''' + > -+
<div>x+ ''' + ), + self.dedent( + ''' +++ ''' + ) + ) + + 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( + ''' ++
+- +
x++ ''' + ) + ) + + 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( + ''' +<!invalid> + 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( + ''' +Note
+ +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( + ''' +- +
+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( + ''' +- Term
+- +
+a
+ +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( + ''' +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 + ''' + ), + '- +
+a
++ ++text
+x1
\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( + ''' +
\n' + '\n' + '
\n' + '- \n' + '\n' + '
\n' + 'text ↩
\n' + '++ ''' + ), + 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 + + +Note
+ +text
+x+ ''' + ), + self.dedent( + ''' +++ ''' + ), + 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
+ +x+++ ''' + ), + 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
+ + +++ ''' + ), + 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 +Note
+para
+ +x+ ''' + ), + self.dedent( + ''' +++ ''' + ), + 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 + +Note
+ +x++ **x** ++ ''' + ), + self.dedent( + ''' +++ ''' + ), + 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
+ +++x
+++ ''' + ), + 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( + ''' +Note
+ ++ ++ ''' + ) + ) + + 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( + ''' +++ ''' + ), + 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 + + + +Note
+ +<!-- b
+text
+++ ''' + ), + 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
+text <!-- + c
+--> more
+Note
+<?php + c
+text
+Note
+<![CDATA[ + c
+text
+
Note
+<![CDATA[ + c
+] ]> + after
+Note
+<!DOCTYPE + c
+text
+<div>
+
+ code
+ </div>
+
+ Title
+<div>
+ code
+ </div>
+
+ Title
+trailing
+after
+Title
+trailing + more
+after
+Title
+trailing
+after
+Title
+ +trailing
+after
+item
+trailing
+after
+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
+
+ Outer
+Inner
+trailing
+after
+