""" Magic Link. pymdownx.magiclink An extension for Python Markdown. Find HTML, FTP links, and email address and turn them to actual links MIT license. Copyright (c) 2014 - 2017 Isaac Muse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from markdown import Extension from markdown.treeprocessors import Treeprocessor from markdown import util as md_util import xml.etree.ElementTree as etree from . import util import re from markdown.inlinepatterns import LinkInlineProcessor, InlineProcessor MAGIC_LINK = 1 MAGIC_AUTO_LINK = 2 DEFAULT_EXCLUDES = { "bitbucket": ['dashboard', 'account', 'plans', 'support', 'repo'], "github": ['marketeplace', 'notifications', 'issues', 'pull', 'sponsors', 'settings', 'support'], "gitlab": ['dashboard', '-', 'explore', 'help', 'projects'], "twitter": ['i', 'messages', 'bookmarks', 'home'] } # Bare link/email detection RE_MAIL = r'''(?xi) (?P (? (?:(?<=\b)|(?<=_))(?: (?:ht|f)tps?://[^_\W][-\w]*(?:\.[-\w.]+)*| # (http|ftp):// (?Pw{3}\.)[^_\W][-\w]*(?:\.[-\w.]+)* # www. ) /?[-\w.?,!'(){}\[\]/+&@%$#=:"|~;]* # url path, fragments, and query stuff (?:[^_\W]|[-/#@$+=]) # allowed end chars ) ''' RE_AUTOLINK = r'(?i)<((?:ht|f)tps?://[^<>]*)>' RE_CUSTOM_NAME = re.compile(r'^[a-zA-Z0-9]+$') # Provider specific user regex rules RE_TWITTER_USER = r'\w{1,15}' RE_GITHUB_USER = r'[a-zA-Z\d](?:[-a-zA-Z\d_]{0,37}[a-zA-Z\d])?' RE_GITLAB_USER = r'[\.a-zA-Z\d_](?:[-a-zA-Z\d_\.]{0,37}[-a-zA-Z\d_])?' RE_BITBUCKET_USER = r'[-a-zA-Z\d_]{1,39}' # External mention patterns RE_ALL_EXT_MENTIONS = r'''(?x) (?P (?(? (?[-._a-zA-Z\d]{{0,99}}[a-zA-Z\d])\b ''' # Internal repo mention patterns RE_GIT_INT_REPO_MENTIONS = r'''(?x) (?P(?[-._a-zA-Z\d]{{0,99}}[a-zA-Z\d])\b ''' # External reference patterns (issue, pull request, commit, compare) RE_GIT_EXT_REFS = r'''(?x) (?P(?\b{})/) (?P\b[-._a-zA-Z\d]{{0,99}}[a-zA-Z\d]) (?:(?P(?:\#|!|\?)[1-9][0-9]*)|(?P@[a-f\d]{{40}})(?:\.{{3}}(?P[a-f\d]{{40}}))?))\b ''' # Internal reference patterns (issue, pull request, commit, compare) RE_GIT_INT_EXT_REFS = r'''(?x) (?P(?\b{})/)? (?P\b[-._a-zA-Z\d]{{0,99}}[a-zA-Z\d]) (?:(?P(?:\#|!|\?)[1-9][0-9]*)|(?P@[a-f\d]{{40}})(?:\.{{3}}(?P[a-f\d]{{40}}))?))\b ''' # Internal reference patterns for default user and repository (issue, pull request, commit, compare) RE_GIT_INT_MICRO_REFS = r'''(?x) (?P (?:(?(?:\#|!|\?)[1-9][0-9]*)|(?P(?[a-f\d]{40}))?) )\b ''' RE_WWW = re.compile(r'(https?://)(?:www\\\.)?(.*)') REPO_LINK_TEMPLATES = { 'github': ( r''' (?P(?P{}/ (?P(?P{})/[^/]+))/ (?:issues/(?P\d+)/?| pull/(?P\d+)/?| discussions/(?P\d+)/?| commit/(?P[\da-f]{{7,40}})/?| compare/(?P[\da-f]{{7,40}})\.{{3}} (?P[\da-f]{{7,40}})))''', RE_GITHUB_USER ), 'bitbucket': ( r''' (?P(?P{}/ (?P(?P{})/[^/]+))/ (?:issues/(?P\d+)(?:/[^/]+)?/?| pull-requests/(?P\d+)(?:/[^/]+(?:/diff)?)?/?| commits/commit/(?P[\da-f]{{7,40}})/?| branches/commits/(?P[\da-f]{{7,40}}) (?:\.{{2}}|%0d)(?P[\da-f]{{7,40}})\#diff))''', RE_BITBUCKET_USER ), 'gitlab': ( r''' (?P(?P{}/ (?P(?P{})/[^/]+))/(?:-/)? (?:issues/(?P\d+)/?| merge_requests/(?P\d+)/?| commit/(?P[\da-f]{{8,40}})/?| compare/(?P[\da-f]{{8,40}})\.{{3}} (?P[\da-f]{{8,40}})))''', RE_GITLAB_USER ) } def create_repo_link_pattern(provider, host, www=True): """Create repository link provider.""" template = REPO_LINK_TEMPLATES[provider] host_pat = re.escape(host.lower().rstrip('/')) if www: m = RE_WWW.match(host_pat) if m: host_pat = m.group(1) + r'(?:w{3}\.)?' + m.group(2) return template[0].format(host_pat, template[1]) # Repository link shortening pattern RE_REPO_LINK = re.compile( r'''(?xi)^(?:{}|{}|{})/?$'''.format( create_repo_link_pattern('github', "https://github.com"), create_repo_link_pattern('bitbucket', "https://bitbucket.org"), create_repo_link_pattern('gitlab', 'https://gitlab.com'), ) ) USER_LINK_TEMPLATES = { 'github': ( r''' (?P(?P{}/ (?P(?P{})(?:/(?P[^/]+))?))) ''', RE_GITHUB_USER ), 'bitbucket': ( r''' (?P(?P{}/ (?P(?P{})(?:/(?P[^/]+)/?)?))) ''', RE_BITBUCKET_USER ), 'gitlab': ( r''' (?P(?P{}/ (?P(?P{})(?:/(?P[^/]+))?))) ''', RE_GITLAB_USER ) } def create_user_link_pattern(provider, host, www=True): """Create repository link provider.""" template = USER_LINK_TEMPLATES[provider] host_pat = re.escape(host.lower().rstrip('/')) if www: m = RE_WWW.match(host_pat) if m: host_pat = m.group(1) + r'(?:w{3}\.)?' + m.group(2) return template[0].format(host_pat, template[1]) # Repository link shortening pattern RE_USER_REPO_LINK = re.compile( r'''(?xi)^(?:{}|{}|{})/?$'''.format( create_user_link_pattern('github', 'https://github.com'), create_user_link_pattern('bitbucket', '"https://bitbucket.org"'), create_user_link_pattern('gitlab', 'https://gitlab.com') ) ) RE_SOCIAL_LINK = re.compile( r'''(?xi) ^(?: (?P(?Phttps://(?:w{{3}}\.)?twitter\.com/(?P{}))) )/?$ '''.format(RE_TWITTER_USER) ) # Provider specific info (links, names, specific patterns, etc.) SOCIAL_PROVIDERS = {'twitter'} # Templates for providers PROVIDER_TEMPLATES = { "gitlab": { "provider": "GitLab", "type": "gitlab", "url": "{}", "user_pattern": RE_GITLAB_USER, "issue": "{}/{{}}/{{}}/-/issues/{{}}", "pull": "{}/{{}}/{{}}/-/merge_requests/{{}}", "commit": "{}/{{}}/{{}}/-/commit/{{}}", "compare": "{}/{{}}/{{}}/-/compare/{{}}...{{}}", "hash_size": 8 }, "bitbucket": { "provider": "Bitbucket", "type": "bitbucket", "url": "{}", "user_pattern": RE_BITBUCKET_USER, "issue": "{}/{{}}/{{}}/issues/{{}}", "pull": "{}/{{}}/{{}}/pull-requests/{{}}", "commit": "{}/{{}}/{{}}/commits/commit/{{}}", "compare": "{}/{{}}/{{}}/branches/commits/{{}}..{{}}#diff", "hash_size": 7 }, "github": { "provider": "GitHub", "type": "github", "url": "{}", "user_pattern": RE_GITHUB_USER, "issue": "{}/{{}}/{{}}/issues/{{}}", "pull": "{}/{{}}/{{}}/pull/{{}}", "discuss": '{}/{{}}/{{}}/discussions/{{}}', "commit": "{}/{{}}/{{}}/commit/{{}}", "compare": "{}/{{}}/{{}}/compare/{{}}...{{}}", "hash_size": 7 }, "twitter": { "provider": "Twitter", "type": "twitter", "url": "{}", "user_pattern": RE_TWITTER_USER }, } def create_provider(provider, host): """Create the provider with the provided host.""" entry = PROVIDER_TEMPLATES[provider].copy() for key in ('url', 'issue', 'pull', 'commit', 'compare', 'discuss'): if key not in entry: continue entry[key] = entry[key].format(host.lower().rstrip('/')) return entry PROVIDER_INFO = { "twitter": create_provider('twitter', "https://twitter.com"), "gitlab": create_provider('gitlab', 'https://gitlab.com'), "bitbucket": create_provider('bitbucket', "https://bitbucket.org"), "github": create_provider('github', "https://github.com") } class _MagiclinkShorthandPattern(InlineProcessor): """Base shorthand link class.""" def __init__(self, pattern, md, user, repo, provider, labels, normalize, provider_info): """Initialize.""" self.user = user self.repo = repo self.labels = labels self.normalize = normalize self.provider_info = provider_info self.provider = provider if provider in self.provider_info else '' InlineProcessor.__init__(self, pattern, md) class _MagiclinkReferencePattern(_MagiclinkShorthandPattern): """Convert #1, repo#1, user/repo#1, !1, repo!1, user/repo!1, hash, repo@hash, or user/repo@hash to links.""" def process_issues(self, el, provider, user, repo, issue): """Process issues.""" issue_type = issue[:1] issue_value = issue[1:] if issue_type == '#': issue_link = self.provider_info[provider]['issue'] issue_label = self.labels.get('issue', 'Issue') class_name = 'magiclink-issue' icon = issue_type elif issue_type == '!': issue_link = self.provider_info[provider]['pull'] issue_label = self.labels.get('pull', 'Pull Request') class_name = 'magiclink-pull' icon = '#' if self.normalize else issue_type elif self.provider_info[provider]['type'] == "github" and issue_type == '?': issue_link = self.provider_info[provider]['discuss'] issue_label = self.labels.get('discuss', 'Discussion') class_name = 'magiclink-discussion' icon = '#' if self.normalize else issue_type else: return False if self.my_repo: el.text = md_util.AtomicString(f'{icon}{issue_value}') elif self.my_user: el.text = md_util.AtomicString(f'{repo}{icon}{issue_value}') else: el.text = md_util.AtomicString(f'{user}/{repo}{icon}{issue_value}') el.set('href', issue_link.format(user, repo, issue_value)) el.set('class', f'magiclink magiclink-{provider} {class_name}') el.set( 'title', '{} {}: {}/{} #{}'.format( self.provider_info[provider]['provider'], issue_label, user, repo, issue_value ) ) return True def process_commit(self, el, provider, user, repo, commit): """Process commit.""" hash_ref = commit[0:self.provider_info[provider]['hash_size']] if self.my_repo: text = hash_ref elif self.my_user: text = f'{repo}@{hash_ref}' else: text = f'{user}/{repo}@{hash_ref}' el.set('href', self.provider_info[provider]['commit'].format(user, repo, commit)) el.text = md_util.AtomicString(text) el.set('class', f'magiclink magiclink-{provider} magiclink-commit') el.set( 'title', '{} {}: {}/{}@{}'.format( self.provider_info[provider]['provider'], self.labels.get('commit', 'Commit'), user, repo, hash_ref ) ) def process_compare(self, el, provider, user, repo, commit1, commit2): """Process commit.""" hash_ref1 = commit1[0:self.provider_info[provider]['hash_size']] hash_ref2 = commit2[0:self.provider_info[provider]['hash_size']] if self.my_repo: text = f'{hash_ref1}...{hash_ref2}' elif self.my_user: text = f'{repo}@{hash_ref1}...{hash_ref2}' else: text = f'{user}/{repo}@{hash_ref1}...{hash_ref2}' el.set('href', self.provider_info[provider]['compare'].format(user, repo, commit1, commit2)) el.text = md_util.AtomicString(text) el.set('class', f'magiclink magiclink-{provider} magiclink-compare') el.set( 'title', '{} {}: {}/{}@{}...{}'.format( self.provider_info[provider]['provider'], self.labels.get('compare', 'Compare'), user, repo, hash_ref1, hash_ref2 ) ) class MagicShortenerTreeprocessor(Treeprocessor): """Tree processor that finds repo issue and commit links and shortens them.""" # Repo link types ISSUE = 0 PULL = 1 COMMIT = 2 DISCUSS = 3 DIFF = 4 REPO = 5 USER = 6 def __init__( self, md, base_url, base_user_url, labels, normalize, repo_shortner, social_shortener, custom_shortners, excludes, provider, provider_info ): """Initialize.""" self.base = base_url self.repo_shortner = repo_shortner self.social_shortener = social_shortener self.custom_shortners = custom_shortners self.base_user = base_user_url self.repo_labels = labels self.normalize = normalize self.provider = provider self.provider_info = provider_info self.labels = { "github": "GitHub", "bitbucket": "Bitbucket", "gitlab": "GitLab" } self.excludes = excludes Treeprocessor.__init__(self, md) def shorten_repo(self, link, class_name, label, user_repo): """Shorten repo link.""" text = user_repo link.text = md_util.AtomicString(text) if 'magiclink-repository' not in class_name: class_name.append('magiclink-repository') link.set( 'title', "{} {}: {}".format( label, self.repo_labels.get('repository', 'Repository'), user_repo ) ) def shorten_user(self, link, class_name, label, user_repo): """Shorten user link.""" link.text = md_util.AtomicString(f'@{user_repo}') if 'magiclink-mention' not in class_name: class_name.append('magiclink-mention') link.set( 'title', "{} {}: {}".format( label, self.repo_labels.get('metion', 'User'), user_repo ) ) def shorten_diff(self, link, class_name, label, user_repo, value, hash_size): """Shorten diff/compare links.""" repo_label = self.repo_labels.get('compare', 'Compare') if self.my_repo: text = f'{value[0][0:hash_size]}...{value[1][0:hash_size]}' elif self.my_user: text = '{}@{}...{}'.format(user_repo.split('/')[1], value[0][0:hash_size], value[1][0:hash_size]) else: text = f'{user_repo}@{value[0][0:hash_size]}...{value[1][0:hash_size]}' link.text = md_util.AtomicString(text) if 'magiclink-compare' not in class_name: class_name.append('magiclink-compare') link.set( 'title', '{} {}: {}@{}...{}'.format( label, repo_label, user_repo.rstrip('/'), value[0][0:hash_size], value[1][0:hash_size] ) ) def shorten_commit(self, link, class_name, label, user_repo, value, hash_size): """Shorten commit link.""" # user/repo@hash repo_label = self.repo_labels.get('commit', 'Commit') if self.my_repo: text = value[0:hash_size] elif self.my_user: text = '{}@{}'.format(user_repo.split('/')[1], value[0:hash_size]) else: text = f'{user_repo}@{value[0:hash_size]}' link.text = md_util.AtomicString(text) if 'magiclink-commit' not in class_name: class_name.append('magiclink-commit') link.set( 'title', '{} {}: {}@{}'.format(label, repo_label, user_repo.rstrip('/'), value[0:hash_size]) ) def shorten_issue(self, provider, link, class_name, label, user_repo, value, link_type): """Shorten issue/pull link.""" # user/repo#(issue|pull) provider_type = self.provider_info[provider]['type'] if link_type == self.ISSUE: issue_type = self.repo_labels.get('issue', 'Issue') icon = '#' if 'magiclink-issue' not in class_name: class_name.append('magiclink-issue') elif link_type == self.PULL: issue_type = self.repo_labels.get('pull', 'Pull Request') icon = '#' if self.normalize else '!' if 'magiclink-pull' not in class_name: class_name.append('magiclink-pull') elif provider_type == 'github' and link_type == self.DISCUSS: issue_type = self.repo_labels.get('discuss', 'Discussion') icon = '#' if self.normalize else '?' if 'magiclink-discussion' not in class_name: class_name.append('magiclink-discussion') if self.my_repo: link.text = md_util.AtomicString(f"{icon}{value}") elif self.my_user: link.text = md_util.AtomicString("{}{}{}".format(user_repo.split('/')[1], icon, value)) else: link.text = md_util.AtomicString(f"{user_repo}{icon}{value}") link.set('title', '{} {}: {} #{}'.format(label, issue_type, user_repo.rstrip('/'), value)) def shorten_issue_commit(self, link, provider, link_type, user_repo, value, hash_size): """Shorten URL.""" label = self.provider_info[provider]['provider'] prov_class = f'magiclink-{provider}' class_attr = link.get('class', '') class_name = class_attr.split(' ') if class_attr else [] if 'magiclink' not in class_name: class_name.append('magiclink') if prov_class not in class_name: class_name.append(prov_class) # Link specific shortening logic if link_type is self.DIFF: self.shorten_diff(link, class_name, label, user_repo, value, hash_size) elif link_type is self.COMMIT: self.shorten_commit(link, class_name, label, user_repo, value, hash_size) else: self.shorten_issue(provider, link, class_name, label, user_repo, value, link_type) link.set('class', ' '.join(class_name)) def shorten_user_repo(self, link, provider, link_type, user_repo): """Shorten URL.""" label = self.provider_info[provider]['provider'] prov_class = f'magiclink-{provider}' class_attr = link.get('class', '') class_name = class_attr.split(' ') if class_attr else [] if 'magiclink' not in class_name: class_name.append('magiclink') if prov_class not in class_name: class_name.append(prov_class) # Link specific shortening logic if link_type is self.REPO: self.shorten_repo(link, class_name, label, user_repo) else: self.shorten_user(link, class_name, label, user_repo) link.set('class', ' '.join(class_name)) def get_provider_type(self, match): """Get the provider and hash size.""" # Set provider specific variables if match.group('github'): provider = 'github' elif match.group('bitbucket'): provider = 'bitbucket' elif match.group('gitlab'): provider = 'gitlab' return provider def get_social_provider(self, match): """Get social provider.""" if match.group('twitter'): provider = 'twitter' return provider def get_type(self, provider, match): """Get the link type.""" try: # Gather info about link type if match.group(provider + '_diff1') is not None: value = (match.group(provider + '_diff1'), match.group(provider + '_diff2')) link_type = self.DIFF elif match.group(provider + '_commit') is not None: value = match.group(provider + '_commit') link_type = self.COMMIT elif match.group(provider + '_pull') is not None: value = match.group(provider + '_pull') link_type = self.PULL elif provider == "github" and match.group(provider + '_discuss') is not None: value = match.group(provider + '_discuss') link_type = self.DISCUSS else: value = match.group(provider + '_issue') link_type = self.ISSUE except IndexError: # Gather info about link type found = False try: if match.group(provider + '_repo') is not None: value = None link_type = self.REPO found = True except IndexError: pass if not found: value = None link_type = self.USER return value, link_type def is_my_repo(self, provider_type, match): """Check if link is from our specified user and repo.""" # See if these links are from the specified repo. return self.base and match.group(provider_type + '_base') + '/' == self.base def is_my_user(self, provider_type, match): """Check if link is from our specified user.""" return self.base_user and match.group(provider_type + '_base').startswith(self.base_user) def excluded(self, provider_type, provider, match): """Check if user has been excluded.""" user = match.group(provider_type + '_user') return user.lower() in self.excludes.get(provider, set()) def run(self, root): """Shorten popular git repository links.""" self.hide_protocol = self.config['hide_protocol'] links = root.iter('a') for link in links: has_child = len(list(link)) is_magic = link.attrib.get('magiclink') href = link.attrib.get('href', '') text = link.text found = False if is_magic: del link.attrib['magiclink'] # We want a normal link. No sub-elements embedded in it, just a normal string. if has_child or not text: # pragma: no cover continue # Make sure the text matches the `href`. If needed, add back protocol to be sure. # Not all links will pass through MagicLink, so we try both with and without protocol. if (text == href or (is_magic and self.hide_protocol and ('https://' + text) == href)): if self.repo_shortner: m = RE_REPO_LINK.match(href) if m: provider_type = self.get_provider_type(m) provider = provider_type self.my_repo = self.is_my_repo(provider_type, m) self.my_user = self.my_repo or self.is_my_user(provider_type, m) value, link_type = self.get_type(provider_type, m) found = True # All right, everything set, let's shorten. if not self.excluded(provider_type, provider, m): self.shorten_issue_commit( link, provider, link_type, m.group(provider_type + '_user_repo'), value, self.provider_info[provider]['hash_size'] ) if not found and self.repo_shortner: m = RE_USER_REPO_LINK.match(href) if m: provider_type = self.get_provider_type(m) provider = provider_type self.my_repo = self.is_my_repo(provider_type, m) self.my_user = self.my_repo or self.is_my_user(provider_type, m) value, link_type = self.get_type(provider_type, m) found = True if not self.excluded(provider_type, provider, m): # All right, everything set, let's shorten. self.shorten_user_repo( link, provider, link_type, m.group(provider_type + '_user_repo') ) if not found and self.custom_shortners: for custom, entry in self.custom_shortners.items(): m = entry['repo'].match(href) if m: provider = custom provider_type = self.provider_info[custom]['type'] self.my_repo = self.is_my_repo(provider_type, m) self.my_user = self.my_repo or self.is_my_user(provider_type, m) value, link_type = self.get_type(provider_type, m) found = True # All right, everything set, let's shorten. if not self.excluded(provider_type, provider, m): self.shorten_issue_commit( link, provider, link_type, m.group(provider_type + '_user_repo'), value, self.provider_info[provider]['hash_size'] ) if not found: m = entry['user'].match(href) if m: provider = custom provider_type = self.provider_info[custom]['type'] self.my_repo = self.is_my_repo(provider_type, m) self.my_user = self.my_repo or self.is_my_user(provider_type, m) value, link_type = self.get_type(provider_type, m) found = True if not self.excluded(provider_type, provider, m): # All right, everything set, let's shorten. self.shorten_user_repo( link, provider, link_type, m.group(provider_type + '_user_repo') ) if not found and self.social_shortener: m = RE_SOCIAL_LINK.match(href) if m: provider = self.get_social_provider(m) self.my_repo = self.is_my_repo(provider, m) self.my_user = self.my_repo or self.is_my_user(provider, m) value, link_type = self.get_type(provider, m) if not self.excluded(provider, provider, m): # All right, everything set, let's shorten. self.shorten_user_repo( link, provider, link_type, m.group(provider + '_user') ) return root class MagiclinkPattern(LinkInlineProcessor): """Convert html, ftp links to clickable links.""" ANCESTOR_EXCLUDES = ('a',) def handleMatch(self, m, data): """Handle URL matches.""" el = etree.Element("a") el.text = md_util.AtomicString(m.group('link')) if m.group("www"): href = "http://{}".format(m.group('link')) else: href = m.group('link') if self.config['hide_protocol']: el.text = md_util.AtomicString(el.text[el.text.find("://") + 3:]) el.set("href", self.unescape(href.strip())) if self.config.get('repo_url_shortener', False): el.set('magiclink', str(MAGIC_LINK)) return el, m.start(0), m.end(0) class MagiclinkAutoPattern(InlineProcessor): """Return a link Element given an auto link ``.""" def handleMatch(self, m, data): """Return link optionally without protocol.""" el = etree.Element("a") el.set('href', self.unescape(m.group(1))) el.text = md_util.AtomicString(m.group(1)) if self.config['hide_protocol']: el.text = md_util.AtomicString(el.text[el.text.find("://") + 3:]) if self.config.get('repo_url_shortener', False): el.set('magiclink', str(MAGIC_AUTO_LINK)) return el, m.start(0), m.end(0) class MagiclinkMailPattern(InlineProcessor): """Convert emails to clickable email links.""" ANCESTOR_EXCLUDES = ('a',) def email_encode(self, code): """Return entity definition by code, or the code if not defined.""" return f"{md_util.AMP_SUBSTITUTE}#{code:d};" def handleMatch(self, m, data): """Handle email link patterns.""" el = etree.Element("a") email = self.unescape(m.group('mail')) href = f"mailto:{email}" el.text = md_util.AtomicString(''.join([self.email_encode(ord(c)) for c in email])) el.set("href", ''.join([md_util.AMP_SUBSTITUTE + f'#{ord(c):d};' for c in href])) return el, m.start(0), m.end(0) class MagiclinkMentionPattern(_MagiclinkShorthandPattern): """Convert @mention to links.""" ANCESTOR_EXCLUDES = ('a',) def handleMatch(self, m, data): """Handle email link patterns.""" text = m.group('mention')[1:] parts = text.split(':') if len(parts) > 1: provider = parts[0] mention = parts[1] else: provider = self.provider mention = parts[0] el = etree.Element("a") el.set('href', '{}/{}'.format(self.provider_info[provider]['url'], mention)) el.set( 'title', "{} {}: {}".format(self.provider_info[provider]['provider'], self.labels.get('mention', "User"), mention) ) el.set('class', f'magiclink magiclink-{provider} magiclink-mention') el.text = md_util.AtomicString(f'@{mention}') return el, m.start(0), m.end(0) class MagiclinkRepositoryPattern(_MagiclinkShorthandPattern): """Convert @user/repo to links.""" ANCESTOR_EXCLUDES = ('a',) def handleMatch(self, m, data): """Handle email link patterns.""" text = m.group('mention')[1:] parts = text.split(':') if len(parts) > 1: provider = parts[0] user = parts[1] else: provider = self.provider user = parts[0] repo = m.group('mention_repo') el = etree.Element("a") el.set('href', '{}/{}/{}'.format(self.provider_info[provider]['url'], user, repo)) el.set( 'title', "{} {}: {}/{}".format( self.provider_info[provider]['provider'], self.labels.get('repository', 'Repository'), user, repo ) ) el.set('class', f'magiclink magiclink-{provider} magiclink-repository') el.text = md_util.AtomicString(f'{user}/{repo}') return el, m.start(0), m.end(0) class MagiclinkExternalRefsPattern(_MagiclinkReferencePattern): """Convert repo#1, user/repo#1, repo!1, user/repo!1, repo@hash, or user/repo@hash to links.""" ANCESTOR_EXCLUDES = ('a',) def handleMatch(self, m, data): """Handle email link patterns.""" is_commit = m.group('commit') is_diff = m.group('diff') value = m.group('commit')[1:] if is_commit else m.group('issue') value2 = m.group('diff') if is_diff else None repo = m.group('repo') user = m.group('user') if not user: user = self.user parts = user.split(':') if len(parts) > 1: provider = parts[0] user = parts[1] else: provider = self.provider # If there is no valid user or provider, reject if not user: return None, None, None self.my_user = user == self.user and provider == self.provider self.my_repo = self.my_user and repo == self.repo el = etree.Element("a") if is_diff: self.process_compare(el, provider, user, repo, value, value2) elif is_commit: self.process_commit(el, provider, user, repo, value) else: if not self.process_issues(el, provider, user, repo, value): return m.group(0), m.start(0), m.end(0) return el, m.start(0), m.end(0) class MagiclinkInternalRefsPattern(_MagiclinkReferencePattern): """Convert #1, !1, and commit_hash.""" ANCESTOR_EXCLUDES = ('a',) def handleMatch(self, m, data): """Handle email link patterns.""" # We don't have a valid provider, user, and repo, reject if not self.user or not self.repo: return None, None, None is_commit = m.group('commit') is_diff = m.group('diff') value = m.group('commit') if is_commit else m.group('issue') value2 = m.group('diff') if is_diff else None repo = self.repo user = self.user provider = self.provider self.my_repo = True self.my_user = True el = etree.Element("a") if is_diff: self.process_compare(el, provider, user, repo, value, value2) elif is_commit: self.process_commit(el, provider, user, repo, value) else: if not self.process_issues(el, provider, user, repo, value): return m.group(0), m.start(0), m.end(0) return el, m.start(0), m.end(0) class MagiclinkExtension(Extension): """Add auto link and link transformation extensions to Markdown class.""" def __init__(self, *args, **kwargs): """Initialize.""" self.config = { 'hide_protocol': [ False, "If 'True', links are displayed without the initial ftp://, http:// or https://" "- Default: False" ], 'repo_url_shortener': [ False, "If 'True' repo commit and issue links are shortened - Default: False" ], 'social_url_shortener': [ False, "If 'True' social links are shortened - Default: False" ], 'shortener_user_exclude': [ { "bitbucket": ['dashboard', 'account', 'plans', 'support', 'repo'], "github": ['marketeplace', 'notifications', 'issues', 'pull', 'sponsors', 'settings', 'support'], "gitlab": ['dashboard', '-', 'explore', 'help', 'projects'], "twitter": ['i', 'messages', 'bookmarks', 'home'] }, "A list of user names to exclude from URL shortening." ], 'repo_url_shorthand': [ False, "If 'True' repo shorthand syntax is converted to links - Default: False" ], 'social_url_shorthand': [ False, "If 'True' social shorthand syntax is converted to links - Default: False" ], 'provider': [ 'github', 'The base provider to use (github, gitlab, bitbucket, twitter) - Default: "github"' ], 'labels': [ {}, "Title labels - Default: {}" ], 'normalize_issue_symbols': [ False, 'Normalize issue, pull, and discussions symbols all to use # - Default: False' ], 'user': [ '', 'The base user name to use - Default: ""' ], 'repo': [ '', 'The base repo to use - Default: ""' ], 'custom': [ {}, "Custom repositories hosts - Default {}" ] } super().__init__(*args, **kwargs) def setup_autolinks(self, md, config): """Setup auto links.""" # Setup general link patterns auto_link_pattern = MagiclinkAutoPattern(RE_AUTOLINK, md) auto_link_pattern.config = config md.inlinePatterns.register(auto_link_pattern, "autolink", 120) link_pattern = MagiclinkPattern(RE_LINK, md) link_pattern.config = config md.inlinePatterns.register(link_pattern, "magic-link", 85) md.inlinePatterns.register(MagiclinkMailPattern(RE_MAIL, md), "magic-mail", 84.9) def setup_shorthand(self, md): """Setup shorthand.""" # Setup URL shortener escape_chars = ['@'] util.escape_chars(md, escape_chars) # Repository shorthand if self.git_short: git_ext_repo = MagiclinkRepositoryPattern( self.re_git_ext_repo_mentions, md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_ext_repo, "magic-repo-ext-mention", 79.9) if not self.is_social: git_int_repo = MagiclinkRepositoryPattern( RE_GIT_INT_REPO_MENTIONS.format(self.int_mentions), md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_int_repo, "magic-repo-int-mention", 79.8) # Mentions pattern = RE_ALL_EXT_MENTIONS.format('|'.join(self.ext_mentions)) git_mention = MagiclinkMentionPattern( pattern, md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_mention, "magic-ext-mention", 79.7) git_mention = MagiclinkMentionPattern( RE_INT_MENTIONS.format(self.int_mentions), md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_mention, "magic-int-mention", 79.6) # Other project refs if self.git_short: git_ext_refs = MagiclinkExternalRefsPattern( self.re_git_ext_refs, md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_ext_refs, "magic-ext-refs", 79.5) if not self.is_social: git_int_refs = MagiclinkExternalRefsPattern( RE_GIT_INT_EXT_REFS.format(self.int_mentions), md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_int_refs, "magic-int-refs", 79.4) git_int_micro_refs = MagiclinkInternalRefsPattern( RE_GIT_INT_MICRO_REFS, md, self.user, self.repo, self.provider, self.labels, self.normalize, self.provider_info ) md.inlinePatterns.register(git_int_micro_refs, "magic-int-micro-refs", 79.3) def setup_shortener( self, md, config ): """Setup shortener.""" shortener = MagicShortenerTreeprocessor( md, self.base_url, self.base_user_url, self.labels, self.normalize, self.repo_shortner, self.social_shortener, self.custom_shortners, self.shortener_exclusions, self.provider, self.provider_info ) shortener.config = config md.treeprocessors.register(shortener, "magic-repo-shortener", 9.9) def get_base_urls(self, config): """Get base URLs.""" base_url = '' base_user_url = '' if self.is_social: return base_url, base_user_url if self.user and self.repo: base_url = '{}/{}/{}/'.format(self.provider_info[self.provider]['url'], self.user, self.repo) base_user_url = '{}/{}/'.format(self.provider_info[self.provider]['url'], self.user) return base_url, base_user_url def extendMarkdown(self, md): """Add support for turning html links and emails to link tags.""" config = self.getConfigs() # Setup repo variables self.user = config.get('user', '') self.repo = config.get('repo', '') self.provider = config.get('provider', 'github') self.labels = config.get('labels', {}) self.normalize = config.get('normalize_issue_symbols', False) self.is_social = self.provider in SOCIAL_PROVIDERS self.git_short = config.get('repo_url_shorthand', False) self.social_short = config.get('social_url_shorthand', False) self.repo_shortner = config.get('repo_url_shortener', False) self.social_shortener = config.get('social_url_shortener', False) self.shortener_exclusions = {k: set(v) for k, v in DEFAULT_EXCLUDES.items()} self.provider_info = PROVIDER_INFO.copy() custom_provider = config.get('custom', {}) excludes = config.get('shortener_user_exclude', {}) self.custom_shortners = {} external_users = [RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS, RE_BITBUCKET_EXT_MENTIONS] for custom, entry in custom_provider.items(): if not RE_CUSTOM_NAME.match(custom): raise ValueError( f"Name '{custom}' not allowed, provider name must contain only letters and numbers" ) if custom not in self.provider_info: self.provider_info[custom] = create_provider(entry['type'], entry['host']) self.provider_info[custom]['provider'] = entry['label'] self.custom_shortners[custom] = { 'repo': re.compile( r'(?xi)^{}/?$'.format( create_repo_link_pattern(entry['type'], entry['host'], entry.get('www', True)) ) ), 'user': re.compile( r'(?xi)^{}/?$'.format( create_user_link_pattern(entry['type'], entry['host'], entry.get('www', True)) ) ) } if custom not in excludes: excludes[custom] = excludes.get(entry['type'], []) external_users.append(create_ext_mentions(custom, entry['type'])) self.re_git_ext_repo_mentions = RE_GIT_EXT_REPO_MENTIONS.format('|'.join(external_users)) self.re_git_ext_refs = RE_GIT_EXT_REFS.format('|'.join(external_users)) for key, value in config.get('shortener_user_exclude', {}).items(): if key in self.provider_info and isinstance(value, (list, tuple, set)): self.shortener_exclusions[key] = {x.lower() for x in value} # Ensure valid provider if self.provider not in self.provider_info: self.provider = 'github' self.setup_autolinks(md, config) if self.git_short or self.social_short: self.ext_mentions = [] if self.git_short: self.ext_mentions.extend(external_users) if self.social_short: self.ext_mentions.append(RE_TWITTER_EXT_MENTIONS) self.int_mentions = self.provider_info[self.provider]['user_pattern'] self.setup_shorthand(md) # Setup link post processor for shortening repository links if self.repo_shortner or self.social_shortener: self.base_url, self.base_user_url = self.get_base_urls(config) self.setup_shortener(md, config) def makeExtension(*args, **kwargs): """Return extension.""" return MagiclinkExtension(*args, **kwargs)