1import re
2from typing import Any, ClassVar, Dict, Iterable, Literal, Mapping, Optional, Tuple, Union
3from urllib.parse import unquote
4
5from ..core import BaseRenderer, BlockState
6from ..util import escape as escape_text
7from ..util import safe_entity, striptags
8
9
10class HTMLRenderer(BaseRenderer):
11 """A renderer for converting Markdown to HTML."""
12
13 _escape: bool
14 _allow_harmful_protocols: Optional[Union[bool, Iterable[str]]]
15 NAME: ClassVar[Literal["html"]] = "html"
16 SAFE_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
17 "http:",
18 "https:",
19 "mailto:",
20 "tel:",
21 "ftp:",
22 "ftps:",
23 "irc:",
24 "ircs:",
25 )
26 GOOD_DATA_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
27 "data:image/gif;",
28 "data:image/png;",
29 "data:image/jpeg;",
30 "data:image/webp;",
31 )
32 _attribute_name_re = re.compile(r"^[A-Za-z_:][A-Za-z0-9_.:-]*$")
33
34 def __init__(
35 self,
36 escape: bool = True,
37 allow_harmful_protocols: Optional[Union[bool, Iterable[str]]] = None,
38 ) -> None:
39 super(HTMLRenderer, self).__init__()
40 self._allow_harmful_protocols = allow_harmful_protocols
41 self._escape = escape
42
43 @property
44 def escape_enabled(self) -> bool:
45 """Whether raw HTML nodes are escaped."""
46 return self._escape
47
48 @escape_enabled.setter
49 def escape_enabled(self, enabled: bool) -> None:
50 self._escape = enabled
51
52 def escape_raw(self, value: str) -> str:
53 """Escape raw HTML when raw HTML output is disabled."""
54 if self.escape_enabled:
55 return escape_text(value)
56 return value
57
58 def escape_attribute(self, value: object) -> str:
59 """Escape a value for use in a generated HTML attribute."""
60 return safe_entity(str(value))
61
62 def render_attrs(self, attrs: Mapping[str, object]) -> str:
63 """Render generated HTML attributes with escaped values."""
64 rendered = []
65 for name, value in attrs.items():
66 if not self._attribute_name_re.fullmatch(name):
67 raise ValueError("invalid HTML attribute name: " + name)
68 if value is None or value is False:
69 continue
70 if value is True:
71 rendered.append(" " + name)
72 else:
73 rendered.append(' ' + name + '="' + self.escape_attribute(value) + '"')
74 return "".join(rendered)
75
76 def sanitize_url(self, url: str) -> str:
77 """Return a URL allowed by this renderer's protocol policy."""
78 allow_harmful_protocols = self._allow_harmful_protocols
79 if allow_harmful_protocols is True:
80 return url
81
82 normalized_url = _unquote_url(url).lower().lstrip()
83 if allow_harmful_protocols and normalized_url.startswith(tuple(allow_harmful_protocols)):
84 return url
85
86 if _is_safe_url(normalized_url, self.SAFE_PROTOCOLS, self.GOOD_DATA_PROTOCOLS):
87 return url
88 return "#harmful-link"
89
90 def render_token(self, token: Dict[str, Any], state: BlockState) -> str:
91 # backward compitable with v2
92 func = self._get_method(token["type"])
93 attrs = token.get("attrs")
94
95 if "raw" in token:
96 text = token["raw"]
97 elif "children" in token:
98 text = self.render_tokens(token["children"], state)
99 else:
100 if attrs:
101 return func(**attrs)
102 else:
103 return func()
104 if attrs:
105 return func(text, **attrs)
106 else:
107 return func(text)
108
109 def safe_url(self, url: str) -> str:
110 """Ensure the given URL is safe. This method is used for rendering
111 links, images, and etc.
112 """
113 return self.escape_attribute(self.sanitize_url(url))
114
115 def text(self, text: str) -> str:
116 if self.escape_enabled:
117 return escape_text(text)
118 return safe_entity(text)
119
120 def emphasis(self, text: str) -> str:
121 return "<em>" + text + "</em>"
122
123 def strong(self, text: str) -> str:
124 return "<strong>" + text + "</strong>"
125
126 def link(self, text: str, url: str, title: Optional[str] = None) -> str:
127 attrs: Dict[str, object] = {"href": self.sanitize_url(url)}
128 if title:
129 attrs["title"] = title
130 return "<a" + self.render_attrs(attrs) + ">" + text + "</a>"
131
132 def image(self, text: str, url: str, title: Optional[str] = None) -> str:
133 alt = striptags(text)
134 attrs: Dict[str, object] = {"src": self.sanitize_url(url), "alt": alt}
135 if title:
136 attrs["title"] = title
137 return "<img" + self.render_attrs(attrs) + " />"
138
139 def codespan(self, text: str) -> str:
140 return "<code>" + escape_text(text) + "</code>"
141
142 def linebreak(self) -> str:
143 return "<br />\n"
144
145 def softbreak(self) -> str:
146 return "\n"
147
148 def inline_html(self, html: str) -> str:
149 return self.escape_raw(html)
150
151 def paragraph(self, text: str) -> str:
152 return "<p>" + text + "</p>\n"
153
154 def heading(self, text: str, level: int, **attrs: Any) -> str:
155 tag = "h" + str(level)
156 _id = attrs.get("id")
157 rendered_attrs = self.render_attrs({"id": _id}) if _id else ""
158 return "<" + tag + rendered_attrs + ">" + text + "</" + tag + ">\n"
159
160 def blank_line(self) -> str:
161 return ""
162
163 def thematic_break(self) -> str:
164 return "<hr />\n"
165
166 def block_text(self, text: str) -> str:
167 return text
168
169 def block_code(self, code: str, info: Optional[str] = None) -> str:
170 attrs: Dict[str, object] = {}
171 if info is not None:
172 info = safe_entity(info.strip())
173 if info:
174 lang = info.split(None, 1)[0]
175 attrs["class"] = "language-" + lang
176 return "<pre><code" + self.render_attrs(attrs) + ">" + escape_text(code) + "</code></pre>\n"
177
178 def block_quote(self, text: str) -> str:
179 return "<blockquote>\n" + text + "</blockquote>\n"
180
181 def block_html(self, html: str) -> str:
182 if self.escape_enabled:
183 return "<p>" + self.escape_raw(html.strip()) + "</p>\n"
184 return html + "\n"
185
186 def block_error(self, text: str) -> str:
187 return '<div class="error"><pre>' + escape_text(text) + "</pre></div>\n"
188
189 def list(self, text: str, ordered: bool, **attrs: Any) -> str:
190 if ordered:
191 start = attrs.get("start")
192 return "<ol" + self.render_attrs({"start": start}) + ">\n" + text + "</ol>\n"
193 return "<ul>\n" + text + "</ul>\n"
194
195 def list_item(self, text: str) -> str:
196 return "<li>" + text + "</li>\n"
197
198
199def _unquote_url(url: str) -> str:
200 for _ in range(3):
201 decoded = unquote(url)
202 if decoded == url:
203 break
204 url = decoded
205 return url
206
207
208def _is_safe_url(url: str, safe_protocols: Tuple[str, ...], good_data_protocols: Tuple[str, ...]) -> bool:
209 if url.startswith(safe_protocols):
210 return True
211 if url.startswith(good_data_protocols):
212 return True
213 if url.startswith(("/", "#", "?")):
214 return True
215 return ":" not in url.split("/", 1)[0]