v0.166.0
  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")