1"""HTML utilities suitable for global use."""
2
3import html
4import json
5from html.parser import HTMLParser
6from typing import Any
7
8from plain.utils.functional import Promise, keep_lazy, keep_lazy_text
9from plain.utils.safestring import SafeString, mark_safe
10
11
12@keep_lazy(SafeString)
13def escape(text: Any) -> SafeString:
14 """
15 Return the given text with ampersands, quotes and angle brackets encoded
16 for use in HTML.
17
18 Always escape input, even if it's already escaped and marked as such.
19 This may result in double-escaping. If this is a concern, use
20 conditional_escape() instead.
21 """
22 return SafeString(html.escape(str(text)))
23
24
25_json_script_escapes = {
26 ord(">"): "\\u003E",
27 ord("<"): "\\u003C",
28 ord("&"): "\\u0026",
29}
30
31
32def json_script(
33 value: Any,
34 element_id: str | None = None,
35 nonce: str = "",
36 encoder: type[json.JSONEncoder] | None = None,
37) -> SafeString:
38 """
39 Escape all the HTML/XML special characters with their unicode escapes, so
40 value is safe to be output anywhere except for inside a tag attribute. Wrap
41 the escaped JSON in a script tag.
42
43 Args:
44 value: The data to encode as JSON
45 element_id: Optional ID attribute for the script tag
46 nonce: Optional CSP nonce for inline script tags
47 encoder: Optional custom JSON encoder class
48 """
49 from plain.json import PlainJSONEncoder
50
51 json_str = json.dumps(value, cls=encoder or PlainJSONEncoder).translate(
52 _json_script_escapes
53 )
54 id_attr = f' id="{element_id}"' if element_id else ""
55 nonce_attr = f' nonce="{nonce}"' if nonce else ""
56 return mark_safe(
57 f'<script{id_attr}{nonce_attr} type="application/json">{json_str}</script>'
58 )
59
60
61def conditional_escape(text: Any) -> SafeString | str:
62 """
63 Similar to escape(), except that it doesn't operate on pre-escaped strings.
64
65 This function relies on the __html__ convention used both by Plain's
66 SafeData class and by third-party libraries like markupsafe.
67 """
68 if isinstance(text, Promise):
69 text = str(text)
70 if hasattr(text, "__html__"):
71 return text.__html__() # ty: ignore[call-non-callable]
72 else:
73 return escape(text)
74
75
76def format_html(format_string: str, *args: Any, **kwargs: Any) -> SafeString:
77 """
78 Similar to str.format, but pass all arguments through conditional_escape(),
79 and call mark_safe() on the result. This function should be used instead
80 of str.format or % interpolation to build up small HTML fragments.
81 """
82 args_safe = map(conditional_escape, args)
83 kwargs_safe = {k: conditional_escape(v) for (k, v) in kwargs.items()}
84 return mark_safe(format_string.format(*args_safe, **kwargs_safe))
85
86
87class MLStripper(HTMLParser):
88 def __init__(self) -> None:
89 super().__init__(convert_charrefs=False)
90 self.reset()
91 self.fed: list[str] = []
92
93 def handle_data(self, data: str) -> None:
94 self.fed.append(data)
95
96 def handle_entityref(self, name: str) -> None:
97 self.fed.append(f"&{name};")
98
99 def handle_charref(self, name: str) -> None:
100 self.fed.append(f"&#{name};")
101
102 def get_data(self) -> str:
103 return "".join(self.fed)
104
105
106def _strip_once(value: str) -> str:
107 """
108 Internal tag stripping utility used by strip_tags.
109 """
110 s = MLStripper()
111 s.feed(value)
112 s.close()
113 return s.get_data()
114
115
116@keep_lazy_text
117def strip_tags(value: Any) -> str:
118 """Return the given HTML with all tags stripped."""
119 # Note: in typical case this loop executes _strip_once once. Loop condition
120 # is redundant, but helps to reduce number of executions of _strip_once.
121 value = str(value)
122 while "<" in value and ">" in value:
123 new_value = _strip_once(value)
124 if value.count("<") == new_value.count("<"):
125 # _strip_once wasn't able to detect more tags.
126 break
127 value = new_value
128 return value
129
130
131def avoid_wrapping(value: str) -> str:
132 """
133 Avoid text wrapping in the middle of a phrase by adding non-breaking
134 spaces where there previously were normal spaces.
135 """
136 return value.replace(" ", "\xa0")