Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,928 changes: 967 additions & 961 deletions docs/changelog.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions markdown/extensions/md_in_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def handle_starttag(self, tag, attrs):

if (
tag in self.block_level_tags and
(self.at_line_start() or self.intail or self.mdstarted and self.mdstarted[-1])
(self.at_line_start() or self.indented_line_start() or self.intail or self.mdstarted and self.mdstarted[-1])
):
# Valueless attribute (ex: `<tag checked>`) results in `[('checked', None)]`.
# Convert to `{'checked': 'checked'}`.
Expand Down Expand Up @@ -279,7 +279,7 @@ def handle_empty_tag(self, data, is_block):
self.handle_data(data)

def parse_pi(self, i: int) -> int:
if self.at_line_start() or self.intail or self.mdstack:
if self.at_line_start() or self.indented_line_start() or self.intail or self.mdstack:
# The same override exists in `HTMLExtractor` without the check
# for `mdstack`. Therefore, use parent of `HTMLExtractor` instead.
return super(HTMLExtractor, self).parse_pi(i)
Expand All @@ -289,7 +289,7 @@ def parse_pi(self, i: int) -> int:
return i + 2

def parse_html_declaration(self, i: int) -> int:
if self.at_line_start() or self.intail or self.mdstack:
if self.at_line_start() or self.indented_line_start() or self.intail or self.mdstack:
if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[':
# We have encountered the bug in #1534 (Python bug `gh-77057`).
# Provide an override until we drop support for Python < 3.13.
Expand Down
126 changes: 118 additions & 8 deletions markdown/htmlparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
import sys
from typing import TYPE_CHECKING, Sequence

from . import util

if TYPE_CHECKING: # pragma: no cover
from markdown import Markdown

Expand Down Expand Up @@ -152,6 +154,7 @@ def __init__(self, md: Markdown, *args, **kwargs):
def reset(self):
"""Reset this instance. Loses all unprocessed data."""
self.inraw = False
self.inraw_indent = 0
self.intail = False
self.stack: list[str] = [] # When `inraw==True`, stack contains a list of tags
self.inline_stack: list[str] = [] # Unclosed raw inline tags in the current paragraph
Expand All @@ -173,8 +176,7 @@ def close(self):
self.handle_data(self.rawdata)
# Handle any unclosed tags.
if len(self._cache):
self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
self._cache = []
self.stash_raw_block()

@property
def line_offset(self) -> int:
Expand Down Expand Up @@ -202,6 +204,76 @@ def at_line_start(self) -> bool:
# Confirm up to first 3 chars are whitespace
return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == ''

def indented_line_start(self) -> bool:
"""
Returns True if current position is at the start of a line which is
preceded by more than three spaces of indentation.

A block-level tag preceded by such indentation may be nested within a
containing block (a list item, admonition, etc.) rather than being part
of an indented code block. The indentation is preserved on the line
which holds the placeholder so the block parser can make that decision.
"""
if self.offset <= 3:
return False
return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == ''

def strip_leading_indent(self) -> None:
"""
Remove the whitespace preceding the current position from `cleandoc`.

The whitespace is re-added to the placeholder line when the raw block
is stashed (see `get_stash_prefix`). This ensures that the placeholder
retains the indentation of the original start tag.
"""
remaining = self.offset
while remaining and self.cleandoc:
chunk = self.cleandoc[-1]
cut = len(chunk) - len(chunk.rstrip(' '))
if not cut:
break
take = min(cut, remaining)
self.cleandoc[-1] = chunk[:-take]
remaining -= take

def get_stash_prefix(self) -> str:
""" Returns the indentation to prefix the raw block placeholder with. """
return ' ' * self.inraw_indent

def dedent_raw(self, text: str) -> str:
"""
Remove the indentation of the raw block's start tag from each line of
the raw block's content (after the first line).
"""
if not self.inraw_indent:
return text
lines = text.split('\n')
for i in range(1, len(lines)):
line = lines[i]
j = 0
while j < self.inraw_indent and j < len(line) and line[j] == ' ':
j += 1
lines[i] = line[j:]
return '\n'.join(lines)

def stash_raw_block(self) -> None:
"""
Store the current raw block in the `htmlStash` and insert its
placeholder into `cleandoc`.

For an indented raw block, the placeholder line preserves the
indentation of the start tag and the stashed content records that
indentation (see [`IndentedRawHtml`][markdown.util.IndentedRawHtml]).
"""
content = self.dedent_raw(''.join(self._cache))
if self.inraw_indent:
html = util.IndentedRawHtml(content, self.inraw_indent)
else:
html = content
self.cleandoc.append(self.get_stash_prefix() + self.md.htmlStash.store(html))
self._cache = []
self.inraw_indent = 0

def get_endtag_text(self, tag: str) -> str:
"""
Returns the text of the end tag.
Expand All @@ -223,10 +295,20 @@ def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]):
self.handle_startendtag(tag, attrs)
return

if self.md.is_block_level(tag) and (self.intail or (self.at_line_start() and not self.inraw)):
if self.md.is_block_level(tag) and (
self.intail or ((self.at_line_start() or self.indented_line_start()) and not self.inraw)
):
# Started a new raw block. Prepare stack.
self.inraw = True
self.cleandoc.append('\n')
if self.intail or self.at_line_start():
self.inraw_indent = 0
self.cleandoc.append('\n')
else:
# The start tag is indented. Preserve the indentation on the
# placeholder line so the block parser can determine whether
# this is a nested raw block or an indented code block.
self.inraw_indent = self.offset
self.strip_leading_indent()

text = self.get_starttag_text()
if self.inraw:
Expand Down Expand Up @@ -254,15 +336,22 @@ def handle_endtag(self, tag: str):
if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(text):]):
# Preserve blank line and end of raw block.
self._cache.append('\n')
if self.inraw_indent:
# The raw block was indented. The blank line which
# follows in the source separates the placeholder from
# any following content, so no extra newlines are
# needed here.
self.inraw = False
self.stash_raw_block()
return
else:
# More content exists after `endtag`.
self.intail = True
# Reset stack.
self.inraw = False
self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
self.stash_raw_block()
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
self._cache = []
else:
self.cleandoc.append(text)
self.close_inline_tag(tag)
Expand Down Expand Up @@ -317,6 +406,27 @@ def handle_empty_tag(self, data: str, is_block: bool):
self.cleandoc.append(self.md.htmlStash.store(data))
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
elif self.indented_line_start() and is_block:
# Handle this as a standalone indented raw block. The indentation
# is preserved on the placeholder line so the block parser can
# determine whether this is nested content or a code block.
self.inraw_indent = self.offset
if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(data):]):
# Preserve blank line after tag in raw block. The blank line
# which follows in the source separates the placeholder from
# any following content, so no extra newlines are needed here.
data += '\n'
self.strip_leading_indent()
self._cache.append(data)
self.stash_raw_block()
else:
# More content exists after tag.
self.intail = True
self.strip_leading_indent()
self._cache.append(data)
self.stash_raw_block()
# Insert blank line between this and next line.
self.cleandoc.append('\n\n')
else:
self.cleandoc.append(data)

Expand Down Expand Up @@ -347,7 +457,7 @@ def unknown_decl(self, data: str):
self.handle_empty_tag('<![{}{}'.format(data, end), is_block=True)

def parse_pi(self, i: int) -> int:
if self.at_line_start() or self.intail:
if self.at_line_start() or self.indented_line_start() or self.intail:
return super().parse_pi(i)
# This is not the beginning of a raw block so treat as plain data
# and avoid consuming any tags which may follow (see #1066).
Expand All @@ -369,7 +479,7 @@ def parse_comment(self, i, report=True):
return match.end()

def parse_html_declaration(self, i: int) -> int:
if self.at_line_start() or self.intail:
if self.at_line_start() or self.indented_line_start() or self.intail:
if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[':
# We have encountered the bug in #1534 (Python bug `gh-77057`).
# Provide an override until we drop support for Python < 3.13.
Expand Down
Loading