Skip to content

Incorrect HTML output for code span containing </> #1528

Closed

Description

@dbader

In markdown==3.8, a code span containing the string "`</>`" is incorrectly rendered. Instead of producing the expected HTML output with the characters escaped, it strips the content entirely:

>>> import markdown
>>> markdown.__version__
'3.8'

>>> markdown.markdown('`</>`')
'<p>``</p>'

Expected result:

`'<p><code>&lt;/&gt;</code></p>'`

Other examples that work correctly:

>>> markdown.markdown('`<`')
✅ '<p><code>&lt;</code></p>'
>>> markdown.markdown('`</`')
✅ '<p><code>&lt;/</code></p>'
>>> markdown.markdown('`</>`')
❌ '<p>``</p>'
>>> markdown.markdown('`/>`')
✅ '<p><code>/&gt;</code></p>'
>>> markdown.markdown('`>`')
✅ '<p><code>&gt;</code></p>'

This issue appears specific to the combination </>. Would appreciate any insight into whether this is a known bug, an edge case in the parser, or an intentional (though surprising) behavior. Thanks!

Activity

  1. facelessuser commented on Apr 25, 2025

    @facelessuser
    Collaborator

    It looks like a bug to me, but it'll need some investigation though to understand why this is happening.

  2. facelessuser commented on Apr 25, 2025

    @facelessuser
    Collaborator

    As far as I can tell, the </> is processed and stripped before the code block is handled, so `` is empty and not processed as a code block. I don't know why, but that will need more analysis.

  3. facelessuser commented on Apr 26, 2025

    @facelessuser
    Collaborator

    So, I was digging deeper into this. This appears to be something that HTMLParser is doing.

    Consider the following example:

    from html.parser import HTMLParser
    from html.entities import name2codepoint
    
    class MyHTMLParser(HTMLParser):
        def handle_starttag(self, tag, attrs):
            print("Start tag:", tag)
            for attr in attrs:
                print("     attr:", attr)
            super().handle_starttag(tag, attrs)
    
        def handle_endtag(self, tag):
            print("End tag  :", tag)
            super().handle_endtag(tag)
    
        def handle_data(self, data):
            print("Data     :", data)
            super().handle_data(data)
    
        def handle_comment(self, data):
            print("Comment  :", data)
            super().handle_comment(data)
    
        def handle_entityref(self, name):
            print('Entity:', name)
            super().handle_entityref(name)
    
        def handle_charref(self, name):
            print("Name:", name)
            super().handle_charref(name)
    
        def handle_decl(self, data):
            print("Decl     :", data)
            super().handle_decl(data)
    
        def handle_comment(self, data: str) -> None:
            print('Comment:', data)
            super().handle_comment(data)
    
        def handle_pi(self, data: str) -> None:
            print('PI:', data)
            super().handle_pi(data)
    
        def unknown_decl(self, data: str) -> None:
            print('Unknown:', data)
            super().unknown_decl(data)
    
    
    parser = MyHTMLParser()
    
    print('==== Test1')
    print(parser.feed('<html><body><</body></html>'))
    
    print('==== Test2')
    print(parser.feed('<html><body></></body></html>'))

    We can see that in the second test that </> is never seen:

    ==== Test1
    Start tag: html
    Start tag: body
    Data     : <
    End tag  : body
    End tag  : html
    None
    ==== Test2
    Start tag: html
    Start tag: body
    End tag  : body
    End tag  : html
    None
    

    I'm not sure what to do with this info currently. From what I can tell, the parser looks at it as a tag, but since it has no name, it is never reported or sent to the handler; it just gets ignored. While this is fine for an HTML parser (browsers do this) a Markdown parser wants to capture that content.

  4. facelessuser commented on Apr 26, 2025

    @facelessuser
    Collaborator

    I've figured out a hacky way to capture and process </>. We have to sneak it in and capture it under something else (start tag, etc.), then we just check for it and send it to data instead.

    It should be noted that putting a space anywhere in </> (< />, </ >, or < / >) also allows this to work with no code modification. The problem only arises when we have exactly </>.

    There may be a less ugly way to handle these than my experimental hack, but as a starting point, the hack does seem to work.

    import importlib.util
    import sys
    import re
    
    spec = importlib.util.find_spec('html.parser')
    htmlparser = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(htmlparser)
    sys.modules['htmlparser'] = htmlparser
    
    
    htmlparser.starttagopen = re.compile('<[a-zA-Z]|</>')
    
    class MyHTMLParser(htmlparser.HTMLParser):
    
        def parse_starttag(self, i):
            if self.rawdata[i:i + 3] == '</>':
                self.handle_data(self.rawdata[i:i + 3])
                return i + 3
            return super().parse_starttag(i)
    
        def handle_starttag(self, tag, attrs):
            print("Start tag:", tag)
            for attr in attrs:
                print("     attr:", attr)
            super().handle_starttag(tag, attrs)
    
        def handle_endtag(self, tag):
            print("End tag  :", tag)
            super().handle_endtag(tag)
    
        def handle_data(self, data):
            print("Data     :", data)
            super().handle_data(data)
    
        def handle_comment(self, data):
            print("Comment  :", data)
            super().handle_comment(data)
    
        def handle_entityref(self, name):
            print('Entity:', name)
            super().handle_entityref(name)
    
        def handle_charref(self, name):
            print("Name:", name)
            super().handle_charref(name)
    
        def handle_decl(self, data):
            print("Decl     :", data)
            super().handle_decl(data)
    
        def handle_comment(self, data: str) -> None:
            print('Comment:', data)
            super().handle_comment(data)
    
        def handle_pi(self, data: str) -> None:
            print('PI:', data)
            super().handle_pi(data)
    
        def unknown_decl(self, data: str) -> None:
            print('Unknown:', data)
            super().unknown_decl(data)
    
    
    parser = MyHTMLParser()
    
    print(parser.feed('<html><body></></body></html>'))
    Start tag: html
    Start tag: body
    Data     : </>
    End tag  : body
    End tag  : html
    None
    

    This seems to work when patched into Markdown without breaking any tests:

    import markdown
    
    text = '''
    `</>`
    '''
    
    html = markdown.markdown(text)
    print(HTML)
    <p><code>&lt;/&gt;</code></p>

    So moving forward, we would just need to settle on the least egregious hack.

  5. added a commit that references this issue on Apr 26, 2025
    4c0c605
  6. facelessuser commented on Apr 26, 2025

    @facelessuser
    Collaborator

    I've pushed a PR to get a discussion going. What I have works, and I've added tests that illustrate what happens in various scenarios. This is basically my current proposal. I am open to other creative approaches, but if no better solution can be determined, then I would vote just to move ahead with it as I don't think ignoring </> is the right approach.

  7. waylan commented on Apr 26, 2025

    @waylan
    Member

    @facelessuser thanks for the full analysis. This is a weird edge case, but I can also understand why the HTMLParser would act the way it does.

  8. added a commit that references this issue on Apr 26, 2025
    64a3c0f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions