Files
devplacepy/devplacepy/rendering.py
T

248 lines
8.2 KiB
Python
Raw Normal View History

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