diff --git a/monty/constants.py b/monty/constants.py index 77fcd71e..0523eaf3 100644 --- a/monty/constants.py +++ b/monty/constants.py @@ -140,6 +140,7 @@ class Emojis: confirmation = "\u2705" decline = "\u274c" + no_choice_light = "\u25fb\ufe0f" x = "\U0001f1fd" o = "\U0001f1f4" diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 13ddff80..090703ea 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -252,9 +252,18 @@ async def fetch_data( await self.request_cache.set(cache_key, (None, body), timeout=timedelta(minutes=30).total_seconds()) return body - def render_github_markdown(self, body: str, *, context: RenderContext = None, limit: int = 700) -> str: + def render_github_markdown(self, body: str, *, context: RenderContext = None, limit: int = 2700) -> str: """Render GitHub Flavored Markdown to Discord flavoured markdown.""" - markdown = mistune.create_markdown(escape=False, renderer=DiscordRenderer()) + url_prefix = context and context.html_url + markdown = mistune.create_markdown( + escape=False, + renderer=DiscordRenderer(repo=url_prefix), + plugins=[ + "strikethrough", + "task_lists", + "url", + ], + ) body = markdown(body) or "" if len(body) > limit: @@ -654,7 +663,9 @@ def format_embed_expanded_issue( body: Optional[str] = json_data["body"] if body and not body.isspace(): # escape wack stuff from the markdown - embed.description = self.render_github_markdown(body, context=None) + embed.description = self.render_github_markdown( + body, context=RenderContext(user=issue.organisation, repo=issue.repository) + ) if not body or body.isspace(): embed.description = "*No description provided.*" return embed diff --git a/monty/utils/markdown.py b/monty/utils/markdown.py index 858698ea..fb5b830c 100644 --- a/monty/utils/markdown.py +++ b/monty/utils/markdown.py @@ -6,7 +6,14 @@ from bs4.element import PageElement, Tag from markdownify import MarkdownConverter +from monty import constants + +__all__ = ( + "remove_codeblocks", + "DocMarkdownConverter", + "DiscordRenderer", +) # taken from version 0.6.1 of markdownify WHITESPACE_RE = re.compile(r"[\r\n\s\t ]+") @@ -16,6 +23,8 @@ re.DOTALL | re.MULTILINE, ) +GH_ISSUE_RE = re.compile(r"(?:GH-|#)(\d+)") + def remove_codeblocks(content: str) -> str: """Remove any codeblock in a message.""" @@ -96,8 +105,18 @@ def convert_hr(self, el: PageElement, text: str, convert_as_inline: bool) -> str class DiscordRenderer(mistune.renderers.BaseRenderer): """Custom renderer for markdown to discord compatiable markdown.""" + def __init__(self, repo: str = None): + self._repo = (repo or "").rstrip("/") + def text(self, text: str) -> str: - """No op.""" + """Replace GitHub links with their expanded versions.""" + if self._repo: + # todo: expand this to all different varieties of automatic links + # if a repository is provided we replace all snippets with the correct thing + def replacement(match: re.Match[str]) -> str: + return self.link(self._repo + "/issues/" + match[1], text=match[0]) + + return GH_ISSUE_RE.sub(replacement, text) return text def link(self, link: str, text: Optional[str] = None, title: Optional[str] = None) -> str: @@ -113,9 +132,9 @@ def link(self, link: str, text: Optional[str] = None, title: Optional[str] = Non else: return link - def image(self, src: str, alt: str = "", title: str = None) -> str: + def image(self, src: str, alt: str = None, title: str = None) -> str: """Return a link to the provided image.""" - return self.link(src, text="image", title=title) + return self.link(src, text="!image", title=alt) def emphasis(self, text: str) -> str: """Return italiced text.""" @@ -128,9 +147,9 @@ def strong(self, text: str) -> str: def heading(self, text: str, level: int) -> str: """Format the heading to be bold if its large enough. Otherwise underline it.""" if level in (1, 2, 3): - return f"**{text}**\n" + return "\n" f"**{text}**\n" else: - return f"__{text}__\n" + return "\n" f"__{text}__\n" def newline(self) -> str: """Return a new line.""" @@ -161,12 +180,12 @@ def block_code(self, code: str, info: str = None) -> str: lang = info.split(None, 1)[0] md += lang md += "\n" - return md + code.replace("`" * 3, "`\u200b" * 3) + "\n```" + return md + code.replace("`" * 3, "`\u200b" * 3) + "\n```\n" def block_quote(self, text: str) -> str: """Quote the provided text.""" if text: - return "> " + "> ".join(text) + "\n" + return "> " + "> ".join(text.rstrip().splitlines(keepends=True)) + "\n" return "" def block_html(self, html: str) -> str: @@ -184,15 +203,23 @@ def codespan(self, text: str) -> str: def paragraph(self, text: str) -> str: """Return a paragraph with a newline postceeding.""" - return text + "\n" + return f"{text}\n\n" def list(self, text: str, ordered: bool, level: int, start: Any = None) -> str: - """Do nothing when encountering a list.""" - return "" + """Return the unedited list.""" + # todo: figure out how this should actually work + if level != 1: + return text + return text.lstrip("\n") + "\n\n" def list_item(self, text: Any, level: int) -> str: - """Do nothing when encountering a list.""" - return "" + """Show the list, indented to its proper level.""" + return "\n" + "\u200b " * (level - 1) * 8 + f"- {text}" + + def task_list_item(self, text: Any, level: int, checked: bool = False, **attrs) -> str: + """Convert task list options to emoji.""" + emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light + return self.list_item(emoji + " " + text, level=level) def finalize(self, data: Any) -> str: """Finalize the data."""