Coverage for webapp/markdown.py: 94%
63 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 22:09 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 22:09 +0000
1import re
2import html
4from mistune import HTMLRenderer, Markdown
5from mistune.block_parser import BlockParser
6from mistune.inline_parser import InlineParser
7from mistune.plugins.formatting import strikethrough
8from mistune.plugins.url import url
9from mistune.util import expand_leading_tab
11# All the overrides were discussed here:
12# https://forum.snapcraft.io/t/use-of-markdown-in-snap-metadata-summary-description/2128
14_INDENT_CODE_TRIM = re.compile(r"^ {1,3}", flags=re.M)
17class SnapcraftBlockParser(BlockParser):
18 SPECIFICATION = {
19 **BlockParser.SPECIFICATION,
20 # Indent code is 3 spaces instead of 4
21 "indent_code": (
22 r"^(?: {3}| *\t)[^\n]+(?:\n+|$)"
23 r"((?:(?: {3}| *\t)[^\n]+(?:\n+|$))|\s)*"
24 ),
25 }
27 # We removed many block rules
28 DEFAULT_RULES = (
29 "blank_line",
30 "thematic_break",
31 "indent_code",
32 "list",
33 )
35 def parse_indent_code(self, m, state):
36 end_pos = state.append_paragraph()
37 if end_pos:
38 return end_pos
39 code = m.group(0)
40 code = expand_leading_tab(code)
41 code = _INDENT_CODE_TRIM.sub("", code)
42 code = code.lstrip("\n")
43 state.append_token(
44 {"type": "block_code", "raw": code, "style": "indent"}
45 )
46 return m.end()
48 def parse_method(self, m, state):
49 # mistune's list parser invokes parse_method for rules outside
50 # DEFAULT_RULES (atx_heading, block_quote, ...) render those as
51 # paragraph text instead of letting the inherited methods run.
52 if m.lastgroup not in self.DEFAULT_RULES:
53 end_pos = state.find_line_end()
54 state.append_token(
55 {"type": "paragraph", "text": state.get_text(end_pos)}
56 )
57 state.cursor = end_pos
58 return end_pos
59 return super().parse_method(m, state)
62class SnapcraftInlineParser(InlineParser):
63 # Override some of InlineParser's functionality to defend against
64 # malicious markdown
65 #
66 # Supported Mistune v3 customization point:
67 # - Inline parser methods can be overridden (see InlineParser API).
68 # https://mistune.lepture.com/en/latest/api.html#mistune.InlineParser
70 SPECIFICATION = {
71 **InlineParser.SPECIFICATION,
72 # Only match a single backtick so triple-backtick fences stay literal
73 "codespan": r"(?<!`)`(?!`)",
74 }
76 # We removed many inline rules
77 DEFAULT_RULES = (
78 "escape",
79 "auto_link",
80 "auto_email",
81 "emphasis",
82 "codespan",
83 "linebreak",
84 "inline_html",
85 # Keep rule enabled for mistune precedence compatibility;
86 # parse_link below turns markdown [text](url) syntax into literal text.
87 "link",
88 )
90 def __init__(self):
91 super().__init__()
92 # Parent __init__ appends "softbreak" drop it so newlines stay as
93 # literal characters in the output
94 if "softbreak" in self.rules:
95 self.rules.remove("softbreak")
97 def parse_link(self, m, state):
98 # Keep markdown link syntax as literal text instead of creating links;
99 # only create anchor tags for the URLs themselves.
100 #
101 # Supported Mistune v3 customization point:
102 # - Inline parser methods can be overridden (see InlineParser API).
103 # https://mistune.lepture.com/en/latest/api.html#mistune.InlineParser
104 #
105 # What we do:
106 # - call the parent's parse_link -> it appends a link token to state
107 # - pop the link token and replace it with:
108 # 1. a raw token for the "[<label>](" part of the link
109 # 2. the link token, but using the actual URL for the label
110 # 3. another raw token for ")"
111 # This method is also used for parsing images, so we add some logic for
112 # that too.
114 pos = super().parse_link(m, state)
116 if not state.tokens:
117 return pos
119 link = state.tokens[-1]
120 if link.get("type") not in {"link", "image"}:
121 return pos
123 link = state.tokens.pop()
124 url = link.get("attrs", {}).get("url")
125 if not url:
126 state.append_token(link)
127 return pos
129 label_children = link.get("children", [])
130 state.append_token({"type": "text", "raw": m.group(0)})
131 for child in label_children:
132 state.append_token(child)
133 state.append_token({"type": "text", "raw": "]("})
134 link["type"] = "link"
135 link["children"] = [{"type": "text", "raw": url}]
136 state.append_token(link)
137 state.append_token({"type": "text", "raw": ")"})
139 return pos
142renderer = HTMLRenderer()
143parser = Markdown(
144 renderer=renderer,
145 block=SnapcraftBlockParser(),
146 inline=SnapcraftInlineParser(),
147 plugins=[strikethrough, url],
148)
151def parse_markdown_description(content):
152 unescaped_content = html.unescape(content)
153 return parser(unescaped_content)