248 lines
8.2 KiB
Python
248 lines
8.2 KiB
Python
# retoor <retoor@molodetz.nl>
|
||||
|
|
|
|||
|
|
import html
|
|||
|
|
import re
|
|||
|
|
from functools import lru_cache
|
|||
|
|
from html.parser import HTMLParser
|
|||
|
|
|
|||
|
|
import mistune
|
|||
|
|
from markupsafe import Markup
|
|||
|
|
|
|||
|
|
EMOJI_MAP = {
|
|||
|
|
"grinning": "\U0001F600", "smiley": "\U0001F603", "smile": "\U0001F604",
|
|||
|
|
"grin": "\U0001F601", "laughing": "\U0001F606", "sweat_smile": "\U0001F605",
|
|||
|
|
"joy": "\U0001F602", "blush": "\U0001F60A", "innocent": "\U0001F607",
|
|||
|
|
"wink": "\U0001F609", "heart_eyes": "\U0001F60D", "kissing_heart": "\U0001F618",
|
|||
|
|
"stuck_out_tongue": "\U0001F61B", "stuck_out_tongue_winking_eye": "\U0001F61C",
|
|||
|
|
"sunglasses": "\U0001F60E", "unamused": "\U0001F612", "sweat": "\U0001F613",
|
|||
|
|
"sob": "\U0001F62D", "scream": "\U0001F631", "sleeping": "\U0001F634",
|
|||
|
|
"relieved": "\U0001F60C", "heart": "❤️", "broken_heart": "\U0001F494",
|
|||
|
|
"two_hearts": "\U0001F495", "sparkling_heart": "\U0001F496",
|
|||
|
|
"star": "⭐", "sparkles": "✨", "zap": "⚡",
|
|||
|
|
"fire": "\U0001F525", "rocket": "\U0001F680", "100": "\U0001F4AF",
|
|||
|
|
"clap": "\U0001F44F", "ok_hand": "\U0001F44C", "wave": "\U0001F44B",
|
|||
|
|
"thumbsup": "\U0001F44D", "+1": "\U0001F44D", "thumbsdown": "\U0001F44E",
|
|||
|
|
"-1": "\U0001F44E", "pray": "\U0001F64F", "muscle": "\U0001F4AA",
|
|||
|
|
"tada": "\U0001F389", "confetti_ball": "\U0001F38A", "party": "\U0001F973",
|
|||
|
|
"beers": "\U0001F37B", "coffee": "☕", "pizza": "\U0001F355",
|
|||
|
|
"computer": "\U0001F4BB", "bug": "\U0001F41B", "gear": "⚙️",
|
|||
|
|
"warning": "⚠️", "question": "❓", "exclamation": "❗",
|
|||
|
|
"checkered_flag": "\U0001F3C1", "eyes": "\U0001F440", "brain": "\U0001F9E0",
|
|||
|
|
"robot": "\U0001F916", "skull": "\U0001F480", "point_up": "☝️",
|
|||
|
|
"point_down": "\U0001F447", "point_left": "\U0001F448", "point_right": "\U0001F449",
|
|||
|
|
"pencil2": "✏️", "memo": "\U0001F4DD", "book": "\U0001F4D6",
|
|||
|
|
"tools": "\U0001F6E0️", "hammer_and_wrench": "\U0001F6E0️",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_SHORTCODE_RE = re.compile(r":([A-Za-z0-9_+\-]+):")
|
|||
|
|
_YOUTUBE_RE = re.compile(
|
|||
|
|
r"(?:https?://)?(?:www\.)?"
|
|||
|
|
r"(?:youtube\.com/(?:watch\?v=|embed/|v/|shorts/)|youtu\.be/)"
|
|||
|
|
r"([A-Za-z0-9_-]{11})"
|
|||
|
|
)
|
|||
|
|
_IMAGE_RE = re.compile(r"\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?.*)?$", re.I)
|
|||
|
|
_VIDEO_RE = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$", re.I)
|
|||
|
|
_AUDIO_RE = re.compile(r"\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$", re.I)
|
|||
|
|
_TOKEN_RE = re.compile(
|
|||
|
|
r"(?P<url>https?://[^\s<]+)"
|
|||
|
|
r"|(?<![A-Za-z0-9_@])@(?P<mention>[A-Za-z0-9_-]+)"
|
|||
|
|
)
|
|||
|
|
_SCHEME_RE = re.compile(r"^([a-z][a-z0-9+.\-]*):", re.I)
|
|||
|
|
_ALLOWED_SCHEMES = {"http", "https", "mailto", "tel"}
|
|||
|
|
|
|||
|
|
_YOUTUBE_ALLOW = (
|
|||
|
|
"accelerometer; autoplay; clipboard-write; encrypted-media; "
|
|||
|
|
"gyroscope; picture-in-picture"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
|
|||
|
|
_TITLE_INLINE_TAGS = {
|
|||
|
|
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_url(url: str) -> str:
|
|||
|
|
value = (url or "").strip()
|
|||
|
|
match = _SCHEME_RE.match(value)
|
|||
|
|
if match and match.group(1).lower() not in _ALLOWED_SCHEMES:
|
|||
|
|
return "#"
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _ContentRenderer(mistune.HTMLRenderer):
|
|||
|
|
def link(self, text: str, url: str, title=None) -> str:
|
|||
|
|
return super().link(text, _safe_url(url), title)
|
|||
|
|
|
|||
|
|
def image(self, text: str, url: str, title=None) -> str:
|
|||
|
|
src = html.escape(_safe_url(url), quote=True)
|
|||
|
|
alt = html.escape(text or "", quote=True)
|
|||
|
|
title_attr = f' title="{html.escape(title, quote=True)}"' if title else ""
|
|||
|
|
return (
|
|||
|
|
f'<img src="{src}" alt="{alt}"{title_attr} '
|
|||
|
|
f'loading="lazy" data-lightbox>'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
_content_markdown = mistune.create_markdown(
|
|||
|
|
hard_wrap=True,
|
|||
|
|
renderer=_ContentRenderer(escape=True),
|
|||
|
|
plugins=["strikethrough", "table"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_dashes(text: str) -> str:
|
|||
|
|
return text.replace("\u2014", "-")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _replace_shortcodes(text: str) -> str:
|
|||
|
|
return _SHORTCODE_RE.sub(
|
|||
|
|
lambda m: EMOJI_MAP.get(m.group(1).lower(), m.group(0)), text
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _embed_url(url: str) -> str:
|
|||
|
|
youtube = _YOUTUBE_RE.search(url)
|
|||
|
|
if youtube:
|
|||
|
|
video_id = youtube.group(1)
|
|||
|
|
return (
|
|||
|
|
f'<div class="embed-youtube"><iframe '
|
|||
|
|
f'src="https://www.youtube.com/embed/{video_id}" '
|
|||
|
|
f'frameborder="0" allowfullscreen allow="{_YOUTUBE_ALLOW}">'
|
|||
|
|
f"</iframe></div>"
|
|||
|
|
)
|
|||
|
|
escaped = html.escape(url, quote=True)
|
|||
|
|
if _IMAGE_RE.search(url):
|
|||
|
|
return f'<img src="{escaped}" alt="" loading="lazy" data-lightbox>'
|
|||
|
|
if _VIDEO_RE.search(url):
|
|||
|
|
return f'<video src="{escaped}" controls preload="metadata"></video>'
|
|||
|
|
if _AUDIO_RE.search(url):
|
|||
|
|
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
|
|||
|
|
return (
|
|||
|
|
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
|
|||
|
|
f"{html.escape(url)}</a>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _transform_text(text: str) -> str:
|
|||
|
|
out: list[str] = []
|
|||
|
|
pos = 0
|
|||
|
|
for match in _TOKEN_RE.finditer(text):
|
|||
|
|
if match.start() > pos:
|
|||
|
|
out.append(html.escape(text[pos:match.start()]))
|
|||
|
|
if match.group("url"):
|
|||
|
|
out.append(_embed_url(match.group("url")))
|
|||
|
|
else:
|
|||
|
|
user = match.group("mention")
|
|||
|
|
out.append(
|
|||
|
|
f'<a href="/profile/{html.escape(user, quote=True)}" '
|
|||
|
|
f'class="mention-link">@{html.escape(user)}</a>'
|
|||
|
|
)
|
|||
|
|
pos = match.end()
|
|||
|
|
if pos < len(text):
|
|||
|
|
out.append(html.escape(text[pos:]))
|
|||
|
|
return "".join(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit_tag(tag: str, attrs: list, self_closing: bool = False) -> str:
|
|||
|
|
parts = [f"<{tag}"]
|
|||
|
|
for key, value in attrs:
|
|||
|
|
if value is None:
|
|||
|
|
parts.append(f" {key}")
|
|||
|
|
else:
|
|||
|
|
parts.append(f' {key}="{html.escape(value, quote=True)}"')
|
|||
|
|
parts.append(" />" if self_closing else ">")
|
|||
|
|
return "".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _MediaProcessor(HTMLParser):
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__(convert_charrefs=True)
|
|||
|
|
self._out: list[str] = []
|
|||
|
|
self._skip_depth = 0
|
|||
|
|
|
|||
|
|
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|||
|
|
self._out.append(_emit_tag(tag, attrs))
|
|||
|
|
if tag in _MEDIA_SKIP_TAGS:
|
|||
|
|
self._skip_depth += 1
|
|||
|
|
|
|||
|
|
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
|||
|
|
self._out.append(_emit_tag(tag, attrs, self_closing=True))
|
|||
|
|
|
|||
|
|
def handle_endtag(self, tag: str) -> None:
|
|||
|
|
if tag in _MEDIA_SKIP_TAGS and self._skip_depth > 0:
|
|||
|
|
self._skip_depth -= 1
|
|||
|
|
self._out.append(f"</{tag}>")
|
|||
|
|
|
|||
|
|
def handle_data(self, data: str) -> None:
|
|||
|
|
if self._skip_depth > 0:
|
|||
|
|
self._out.append(html.escape(data))
|
|||
|
|
else:
|
|||
|
|
self._out.append(_transform_text(data))
|
|||
|
|
|
|||
|
|
def result(self) -> str:
|
|||
|
|
return "".join(self._out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _InlineFilter(HTMLParser):
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__(convert_charrefs=True)
|
|||
|
|
self._out: list[str] = []
|
|||
|
|
|
|||
|
|
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|||
|
|
if tag in _TITLE_INLINE_TAGS:
|
|||
|
|
self._out.append(f"<{tag}>")
|
|||
|
|
|
|||
|
|
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
|||
|
|
if tag in _TITLE_INLINE_TAGS:
|
|||
|
|
self._out.append(f"<{tag}>")
|
|||
|
|
|
|||
|
|
def handle_endtag(self, tag: str) -> None:
|
|||
|
|
if tag in _TITLE_INLINE_TAGS and tag != "br":
|
|||
|
|
self._out.append(f"</{tag}>")
|
|||
|
|
|
|||
|
|
def handle_data(self, data: str) -> None:
|
|||
|
|
self._out.append(html.escape(data))
|
|||
|
|
|
|||
|
|
def result(self) -> str:
|
|||
|
|
return "".join(self._out).strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _process_media(rendered: str) -> str:
|
|||
|
|
processor = _MediaProcessor()
|
|||
|
|
processor.feed(rendered)
|
|||
|
|
processor.close()
|
|||
|
|
return processor.result()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _keep_inline(rendered: str) -> str:
|
|||
|
|
inline = _InlineFilter()
|
|||
|
|
inline.feed(rendered)
|
|||
|
|
inline.close()
|
|||
|
|
return inline.result()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@lru_cache(maxsize=4096)
|
|||
|
|
def _render_content(text: str) -> str:
|
|||
|
|
text = _normalize_dashes(text)
|
|||
|
|
text = _replace_shortcodes(text)
|
|||
|
|
return _process_media(_content_markdown(text))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@lru_cache(maxsize=4096)
|
|||
|
|
def _render_title(text: str) -> str:
|
|||
|
|
text = _normalize_dashes(text)
|
|||
|
|
text = _replace_shortcodes(text)
|
|||
|
|
return _keep_inline(_content_markdown(text))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_content(text) -> Markup:
|
|||
|
|
if not text:
|
|||
|
|
return Markup("")
|
|||
|
|
return Markup(_render_content(str(text)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_title(text) -> Markup:
|
|||
|
|
if not text:
|
|||
|
|
return Markup("")
|
|||
|
|
return Markup(_render_title(str(text)))
|