Coverage for webapp/markdown.py: 94%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-20 22:09 +0000

1import re 

2import html 

3 

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 

10 

11# All the overrides were discussed here: 

12# https://forum.snapcraft.io/t/use-of-markdown-in-snap-metadata-summary-description/2128 

13 

14_INDENT_CODE_TRIM = re.compile(r"^ {1,3}", flags=re.M) 

15 

16 

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 } 

26 

27 # We removed many block rules 

28 DEFAULT_RULES = ( 

29 "blank_line", 

30 "thematic_break", 

31 "indent_code", 

32 "list", 

33 ) 

34 

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() 

47 

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) 

60 

61 

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 

69 

70 SPECIFICATION = { 

71 **InlineParser.SPECIFICATION, 

72 # Only match a single backtick so triple-backtick fences stay literal 

73 "codespan": r"(?<!`)`(?!`)", 

74 } 

75 

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 ) 

89 

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") 

96 

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. 

113 

114 pos = super().parse_link(m, state) 

115 

116 if not state.tokens: 

117 return pos 

118 

119 link = state.tokens[-1] 

120 if link.get("type") not in {"link", "image"}: 

121 return pos 

122 

123 link = state.tokens.pop() 

124 url = link.get("attrs", {}).get("url") 

125 if not url: 

126 state.append_token(link) 

127 return pos 

128 

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": ")"}) 

138 

139 return pos 

140 

141 

142renderer = HTMLRenderer() 

143parser = Markdown( 

144 renderer=renderer, 

145 block=SnapcraftBlockParser(), 

146 inline=SnapcraftInlineParser(), 

147 plugins=[strikethrough, url], 

148) 

149 

150 

151def parse_markdown_description(content): 

152 unescaped_content = html.unescape(content) 

153 return parser(unescaped_content)