From e6fe3551d2582b9fd6f1a521e2f76d50afcf0d74 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Tue, 26 Dec 2023 12:33:13 -0700 Subject: [PATCH] Allow configuring custom repo provider (#2277) * Allow configuring custom repo provider Resolves #933 * Clean up * Add documentation * Bump version --- docs/src/dictionary/en-custom.txt | 1 + docs/src/markdown/about/changelog.md | 8 +- docs/src/markdown/extensions/magiclink.md | 34 ++ pymdownx/__meta__.py | 2 +- pymdownx/magiclink.py | 534 ++++++++++++++++------ tests/test_extensions/test_magiclink.py | 182 +++++++- 6 files changed, 615 insertions(+), 146 deletions(-) diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt index 9ddcf2b1a..8e2544de2 100644 --- a/docs/src/dictionary/en-custom.txt +++ b/docs/src/dictionary/en-custom.txt @@ -263,6 +263,7 @@ syntaxes th theming thumbsup +tooltips tox uc un diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 58a734757..e0c354217 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -1,9 +1,13 @@ # Changelog +## 10.6 + +- **NEW**: MagicLink: Allow configuring custom repository providers based off the existing providers. + ## 10.5 -- **NEW**: Blocks: Admonitions and Details now allow configuring custom block classes and default titles. -- **FIX**: Keys: Ensure that Keys does not parse base64 encoded URLs. +- **NEW**: Blocks: Admonitions and Details now allow configuring custom block classes and default titles. +- **FIX**: Keys: Ensure that Keys does not parse base64 encoded URLs. ## 10.4 diff --git a/docs/src/markdown/extensions/magiclink.md b/docs/src/markdown/extensions/magiclink.md index e1fac358c..d4cc7182e 100644 --- a/docs/src/markdown/extensions/magiclink.md +++ b/docs/src/markdown/extensions/magiclink.md @@ -445,6 +445,40 @@ repository name. You can override them and add more via the option [`shortener_u MagicLink added user name and repository name link shortening along. /// +## Custom Repository Hosts + +It is possible that someone may be running their own private GitHub, GitLab, or Bitbucket repository. MagicLink allows +for the creating variants of either of these repository providers with a custom host. MagicLink provides no additional +control over specifics, so if this is sufficient for your needs, then it give it a try! + +To specify a custom provider, you simply need to specify them via the `custom` option. + +1. Simply specify the name to identify the provider (must only contain alphanumeric characters). Provider name is used + when manually specifying a provider (`@provider:user`) and will be used to generate custom CSS classes + `magiclink-provider`. +2. Specify the `type`. Is this a private `github`, `gitlab`, or `bitbucket` provider. +3. Specify the `label` for tooltips. +4. Specify the `host` for your private repository. + +```js +'custom': { + 'test': { + 'host': 'http://test.com', + 'label': 'Test', + 'type': 'github' + } +} +``` + +Host URLs assume the `www` subdomain, and will generate the URL pattern to capture explicit or implicit `www` in host +URLs, whether you specify it in the host URL or not. If your repository does not use `www` subdomain, then set the +option `www` to `#!py False`. Most people will never need to touch this. + +Lastly, `shortener_user_exclude` will assume your custom provider requires the same exclude list of the specified `type` +and will copy them for your custom repository. If this is not sufficient, you can add an entry to +`shortener_user_exclude` for your custom repository provider using your specified `name`. If you manually set excludes +in this manner, no excludes from the same `type` will be copied over. + ## CSS For normal links, no classes are added to the anchor tags. For repository links, `magiclink` will be added as a class. diff --git a/pymdownx/__meta__.py b/pymdownx/__meta__.py index 4d3dd919e..99d48b0b1 100644 --- a/pymdownx/__meta__.py +++ b/pymdownx/__meta__.py @@ -185,5 +185,5 @@ def parse_version(ver, pre=False): return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(10, 5, 0, "final") +__version_info__ = Version(10, 6, 0, "final") __version__ = __version_info__._get_canonical() diff --git a/pymdownx/magiclink.py b/pymdownx/magiclink.py index 4174d731e..dc1199ea4 100644 --- a/pymdownx/magiclink.py +++ b/pymdownx/magiclink.py @@ -64,6 +64,8 @@ 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])?' @@ -77,14 +79,25 @@ (?:{}) )\b ''' -RE_TWITTER_EXT_MENTIONS = r'twitter:{}'.format(RE_TWITTER_USER) -RE_GITHUB_EXT_MENTIONS = r'github:{}'.format(RE_GITHUB_USER) -RE_GITLAB_EXT_MENTIONS = r'gitlab:{}'.format(RE_GITLAB_USER) -RE_BITBUCKET_EXT_MENTIONS = r'bitbucket:{}'.format(RE_BITBUCKET_USER) # Internal mention patterns RE_INT_MENTIONS = r'(?P(? @@ -92,7 +105,7 @@ @(?:{}) )\b /(?P[-._a-zA-Z\d]{{0,99}}[a-zA-Z\d])\b -'''.format('|'.join([RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS, RE_BITBUCKET_EXT_MENTIONS])) +''' # Internal repo mention patterns RE_GIT_INT_REPO_MENTIONS = r'''(?x) @@ -105,7 +118,7 @@ (?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 -'''.format('|'.join([RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS, RE_BITBUCKET_EXT_MENTIONS])) +''' # Internal reference patterns (issue, pull request, commit, compare) RE_GIT_INT_EXT_REFS = r'''(?x) @@ -121,50 +134,112 @@ )\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) - ^(?: - (?P(?Phttps://(?:w{{3}}\.)?github\.com/ - (?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}})))| - - (?P(?Phttps://(?:w{{3}}\.)?bitbucket\.org/ - (?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))| - - (?P(?Phttps://(?:w{{3}}\.)?gitlab\.com/ - (?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}}))) - )/?$ - '''.format(RE_GITHUB_USER, RE_BITBUCKET_USER, RE_GITLAB_USER) + 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) - ^(?: - (?P(?Phttps://(?:w{{3}}\.)?github\.com/ - (?P(?P{})(?:/(?P[^/]+))?))) | - (?P(?Phttps://(?:w{{3}}\.)?bitbucket\.org/ - (?P(?P{})(?:/(?P[^/]+)/?)?))) | - (?P(?Phttps://(?:w{{3}}\.)?gitlab\.com/ - (?P(?P{})(?:/(?P[^/]+))?))) | - )/?$ - '''.format(RE_GITHUB_USER, RE_BITBUCKET_USER, RE_GITLAB_USER) + 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( @@ -177,57 +252,83 @@ # Provider specific info (links, names, specific patterns, etc.) SOCIAL_PROVIDERS = {'twitter'} -PROVIDER_INFO = { - "twitter": { - "provider": "Twitter", - "url": "https://twitter.com", - "user_pattern": RE_TWITTER_USER - }, + +# Templates for providers +PROVIDER_TEMPLATES = { "gitlab": { "provider": "GitLab", - "url": "https://gitlab.com", + "type": "gitlab", + "url": "{}", "user_pattern": RE_GITLAB_USER, - "issue": "https://gitlab.com/{}/{}/-/issues/{}", - "pull": "https://gitlab.com/{}/{}/-/merge_requests/{}", - "commit": "https://gitlab.com/{}/{}/-/commit/{}", - "compare": "https://gitlab.com/{}/{}/-/compare/{}...{}", + "issue": "{}/{{}}/{{}}/-/issues/{{}}", + "pull": "{}/{{}}/{{}}/-/merge_requests/{{}}", + "commit": "{}/{{}}/{{}}/-/commit/{{}}", + "compare": "{}/{{}}/{{}}/-/compare/{{}}...{{}}", "hash_size": 8 }, "bitbucket": { "provider": "Bitbucket", - "url": "https://bitbucket.org", + "type": "bitbucket", + "url": "{}", "user_pattern": RE_BITBUCKET_USER, - "issue": "https://bitbucket.org/{}/{}/issues/{}", - "pull": "https://bitbucket.org/{}/{}/pull-requests/{}", - "commit": "https://bitbucket.org/{}/{}/commits/commit/{}", - "compare": "https://bitbucket.org/{}/{}/branches/commits/{}..{}#diff", + "issue": "{}/{{}}/{{}}/issues/{{}}", + "pull": "{}/{{}}/{{}}/pull-requests/{{}}", + "commit": "{}/{{}}/{{}}/commits/commit/{{}}", + "compare": "{}/{{}}/{{}}/branches/commits/{{}}..{{}}#diff", "hash_size": 7 }, "github": { "provider": "GitHub", - "url": "https://github.com", + "type": "github", + "url": "{}", "user_pattern": RE_GITHUB_USER, - "issue": "https://github.com/{}/{}/issues/{}", - "pull": "https://github.com/{}/{}/pull/{}", - "discuss": 'https://github.com/{}/{}/discussions/{}', - "commit": "https://github.com/{}/{}/commit/{}", - "compare": "https://github.com/{}/{}/compare/{}...{}", + "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): + 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 = provider if provider in PROVIDER_INFO else '' + self.provider_info = provider_info + self.provider = provider if provider in self.provider_info else '' InlineProcessor.__init__(self, pattern, md) @@ -241,17 +342,17 @@ def process_issues(self, el, provider, user, repo, issue): issue_value = issue[1:] if issue_type == '#': - issue_link = PROVIDER_INFO[provider]['issue'] + 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 = PROVIDER_INFO[provider]['pull'] + 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 provider == "github" and issue_type == '?': - issue_link = PROVIDER_INFO[provider]['discuss'] + 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 @@ -270,7 +371,7 @@ def process_issues(self, el, provider, user, repo, issue): el.set( 'title', '{} {}: {}/{} #{}'.format( - PROVIDER_INFO[provider]['provider'], + self.provider_info[provider]['provider'], issue_label, user, repo, @@ -282,7 +383,7 @@ def process_issues(self, el, provider, user, repo, issue): def process_commit(self, el, provider, user, repo, commit): """Process commit.""" - hash_ref = commit[0:PROVIDER_INFO[provider]['hash_size']] + hash_ref = commit[0:self.provider_info[provider]['hash_size']] if self.my_repo: text = hash_ref elif self.my_user: @@ -290,13 +391,13 @@ def process_commit(self, el, provider, user, repo, commit): else: text = '{}/{}@{}'.format(user, repo, hash_ref) - el.set('href', PROVIDER_INFO[provider]['commit'].format(user, repo, commit)) + el.set('href', self.provider_info[provider]['commit'].format(user, repo, commit)) el.text = md_util.AtomicString(text) el.set('class', 'magiclink magiclink-{} magiclink-commit'.format(provider)) el.set( 'title', '{} {}: {}/{}@{}'.format( - PROVIDER_INFO[provider]['provider'], + self.provider_info[provider]['provider'], self.labels.get('commit', 'Commit'), user, repo, @@ -307,8 +408,8 @@ def process_commit(self, el, provider, user, repo, commit): def process_compare(self, el, provider, user, repo, commit1, commit2): """Process commit.""" - hash_ref1 = commit1[0:PROVIDER_INFO[provider]['hash_size']] - hash_ref2 = commit2[0:PROVIDER_INFO[provider]['hash_size']] + 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 = '{}...{}'.format(hash_ref1, hash_ref2) elif self.my_user: @@ -316,13 +417,13 @@ def process_compare(self, el, provider, user, repo, commit1, commit2): else: text = '{}/{}@{}...{}'.format(user, repo, hash_ref1, hash_ref2) - el.set('href', PROVIDER_INFO[provider]['compare'].format(user, repo, commit1, commit2)) + el.set('href', self.provider_info[provider]['compare'].format(user, repo, commit1, commit2)) el.text = md_util.AtomicString(text) el.set('class', 'magiclink magiclink-{} magiclink-compare'.format(provider)) el.set( 'title', '{} {}: {}/{}@{}...{}'.format( - PROVIDER_INFO[provider]['provider'], + self.provider_info[provider]['provider'], self.labels.get('compare', 'Compare'), user, repo, @@ -345,17 +446,30 @@ class MagicShortenerTreeprocessor(Treeprocessor): USER = 6 def __init__( - self, md, base_url, base_user_url, labels, normalize, repo_shortner, social_shortener, excludes, provider + 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", @@ -442,6 +556,7 @@ def shorten_issue(self, provider, link, class_name, label, user_repo, value, lin """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 = '#' @@ -452,7 +567,7 @@ def shorten_issue(self, provider, link, class_name, label, user_repo, value, lin icon = '#' if self.normalize else '!' if 'magiclink-pull' not in class_name: class_name.append('magiclink-pull') - elif provider == 'github' and link_type == self.DISCUSS: + 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: @@ -470,7 +585,7 @@ def shorten_issue(self, provider, link, class_name, label, user_repo, value, lin def shorten_issue_commit(self, link, provider, link_type, user_repo, value, hash_size): """Shorten URL.""" - label = PROVIDER_INFO[provider]['provider'] + label = self.provider_info[provider]['provider'] prov_class = 'magiclink-{}'.format(provider) class_attr = link.get('class', '') class_name = class_attr.split(' ') if class_attr else [] @@ -493,7 +608,7 @@ def shorten_issue_commit(self, link, provider, link_type, user_repo, value, hash def shorten_user_repo(self, link, provider, link_type, user_repo): """Shorten URL.""" - label = PROVIDER_INFO[provider]['provider'] + label = self.provider_info[provider]['provider'] prov_class = 'magiclink-{}'.format(provider) class_attr = link.get('class', '') class_name = class_attr.split(' ') if class_attr else [] @@ -511,7 +626,7 @@ def shorten_user_repo(self, link, provider, link_type, user_repo): self.shorten_user(link, class_name, label, user_repo) link.set('class', ' '.join(class_name)) - def get_provider(self, match): + def get_provider_type(self, match): """Get the provider and hash size.""" # Set provider specific variables @@ -565,21 +680,21 @@ def get_type(self, provider, match): link_type = self.USER return value, link_type - def is_my_repo(self, provider, match): + 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 + '_base') + '/' == self.base + return self.base and match.group(provider_type + '_base') + '/' == self.base - def is_my_user(self, provider, match): + def is_my_user(self, provider_type, match): """Check if link is from our specified user.""" - return self.base_user and match.group(provider + '_base').startswith(self.base_user) + return self.base_user and match.group(provider_type + '_base').startswith(self.base_user) - def excluded(self, provider, match): + def excluded(self, provider_type, provider, match): """Check if user has been excluded.""" - user = match.group(provider + '_user') + user = match.group(provider_type + '_user') return user.lower() in self.excludes.get(provider, set()) def run(self, root): @@ -608,39 +723,81 @@ def run(self, root): if self.repo_shortner: m = RE_REPO_LINK.match(href) if m: - provider = self.get_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) + 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, m): + if not self.excluded(provider_type, provider, m): self.shorten_issue_commit( link, provider, link_type, - m.group(provider + '_user_repo'), + m.group(provider_type + '_user_repo'), value, - PROVIDER_INFO[provider]['hash_size'] + self.provider_info[provider]['hash_size'] ) if not found and self.repo_shortner: m = RE_USER_REPO_LINK.match(href) if m: - provider = self.get_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) + 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, m): + 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 + '_user_repo') + 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: @@ -649,7 +806,7 @@ def run(self, root): 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, m): + if not self.excluded(provider, provider, m): # All right, everything set, let's shorten. self.shorten_user_repo( link, @@ -740,10 +897,10 @@ def handleMatch(self, m, data): mention = parts[0] el = etree.Element("a") - el.set('href', '{}/{}'.format(PROVIDER_INFO[provider]['url'], mention)) + el.set('href', '{}/{}'.format(self.provider_info[provider]['url'], mention)) el.set( 'title', - "{} {}: {}".format(PROVIDER_INFO[provider]['provider'], self.labels.get('mention', "User"), mention) + "{} {}: {}".format(self.provider_info[provider]['provider'], self.labels.get('mention', "User"), mention) ) el.set('class', 'magiclink magiclink-{} magiclink-mention'.format(provider)) el.text = md_util.AtomicString('@{}'.format(mention)) @@ -770,11 +927,11 @@ def handleMatch(self, m, data): repo = m.group('mention_repo') el = etree.Element("a") - el.set('href', '{}/{}/{}'.format(PROVIDER_INFO[provider]['url'], user, repo)) + el.set('href', '{}/{}/{}'.format(self.provider_info[provider]['url'], user, repo)) el.set( 'title', "{} {}: {}/{}".format( - PROVIDER_INFO[provider]['provider'], self.labels.get('repository', 'Repository'), user, repo + self.provider_info[provider]['provider'], self.labels.get('repository', 'Repository'), user, repo ) ) el.set('class', 'magiclink magiclink-{} magiclink-repository'.format(provider)) @@ -915,6 +1072,10 @@ def __init__(self, *args, **kwargs): 'repo': [ '', 'The base repo to use - Default: ""' + ], + 'custom': [ + {}, + "Custom repositories hosts - Default {}" ] } super().__init__(*args, **kwargs) @@ -933,7 +1094,7 @@ def setup_autolinks(self, md, config): md.inlinePatterns.register(MagiclinkMailPattern(RE_MAIL, md), "magic-mail", 84.9) - def setup_shorthand(self, md, int_mentions, ext_mentions, config): + def setup_shorthand(self, md): """Setup shorthand.""" # Setup URL shortener @@ -943,51 +1104,111 @@ def setup_shorthand(self, md, int_mentions, ext_mentions, config): # Repository shorthand if self.git_short: git_ext_repo = MagiclinkRepositoryPattern( - RE_GIT_EXT_REPO_MENTIONS, md, self.user, self.repo, self.provider, self.labels, self.normalize + 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(int_mentions), md, self.user, self.repo, self.provider, - self.labels, self.normalize + 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(ext_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 + 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(int_mentions), md, self.user, self.repo, self.provider, self.labels, self.normalize + 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( - RE_GIT_EXT_REFS, md, self.user, self.repo, self.provider, self.labels, self.normalize + 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(int_mentions), md, self.user, self.repo, self.provider, - self.labels, self.normalize + 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 + 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, base_url, base_user_url, config, repo_shortner, social_shortener): + def setup_shortener( + self, + md, + config + ): """Setup shortener.""" shortener = MagicShortenerTreeprocessor( - md, base_url, base_user_url, self.labels, self.normalize, repo_shortner, social_shortener, - self.shortener_exclusions, self.provider + 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) @@ -1002,8 +1223,8 @@ def get_base_urls(self, config): return base_url, base_user_url if self.user and self.repo: - base_url = '{}/{}/{}/'.format(PROVIDER_INFO[self.provider]['url'], self.user, self.repo) - base_user_url = '{}/{}/'.format(PROVIDER_INFO[self.provider]['url'], self.user) + 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 @@ -1024,34 +1245,63 @@ def extendMarkdown(self, md): 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( + "Name '{}' not allowed, provider name must contain only letters and numbers".format(custom) + ) + 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 ('github', 'bitbucket', 'gitlab', 'twitter') and isinstance(value, (list, tuple, set)): + 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 PROVIDER_INFO: + if self.provider not in self.provider_info: self.provider = 'github' - int_mentions = None - ext_mentions = [] - if self.git_short: - ext_mentions.extend([RE_BITBUCKET_EXT_MENTIONS, RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS]) - - if self.social_short: - ext_mentions.append(RE_TWITTER_EXT_MENTIONS) - - if self.git_short or self.social_short: - int_mentions = PROVIDER_INFO[self.provider]['user_pattern'] - self.setup_autolinks(md, config) if self.git_short or self.social_short: - self.setup_shorthand(md, int_mentions, ext_mentions, config) + 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: - base_url, base_user_url = self.get_base_urls(config) - self.setup_shortener(md, base_url, base_user_url, config, self.repo_shortner, self.social_shortener) + self.base_url, self.base_user_url = self.get_base_urls(config) + self.setup_shortener(md, config) def makeExtension(*args, **kwargs): diff --git a/tests/test_extensions/test_magiclink.py b/tests/test_extensions/test_magiclink.py index 9c2e246d9..872dbab6f 100644 --- a/tests/test_extensions/test_magiclink.py +++ b/tests/test_extensions/test_magiclink.py @@ -1,5 +1,6 @@ """Test cases for MagicLink.""" from .. import util +import markdown class TestMagicLinkShortner(util.MdCase): @@ -127,7 +128,7 @@ class TestMagicLinkShortnerSocial(util.MdCase): """Test cases for social link shortener.""" extension = [ - 'pymdownx.magiclink', + 'pymdownx.magiclink' ] extension_configs = { @@ -160,3 +161,182 @@ def test_excluded(self): r'https://twitter.com/home', r'

https://twitter.com/home

' ) + + +class TestMagicLinkCustom(util.MdCase): + """Test cases for custom provider.""" + + extension = [ + 'pymdownx.magiclink', + 'pymdownx.saneheaders' + ] + + extension_configs = { + 'pymdownx.magiclink': { + 'repo_url_shorthand': True, + 'repo_url_shortener': True, + 'user': 'facelessuser', + 'repo': 'pymdown-extensions', + 'provider': 'test', + 'custom': { + 'test': { + 'host': 'http://test.com', + 'label': 'Test', + 'type': 'github' + } + } + } + } + + def test_user(self): + """Test user in custom repo.""" + + self.check_markdown( + '@facelessuser', + '

@facelessuser

' # noqa: E501 + ) + + def test_repo(self): + """Test repo in custom repo.""" + + self.check_markdown( + '@facelessuser/pymdown-extensions', + '

facelessuser/pymdown-extensions

' # noqa: E501 + ) + + def test_default_issue(self): + """Test default issue case.""" + + self.check_markdown( + '#2', + '

#2

' # noqa: E501 + ) + + def test_default_pull(self): + """Test default pull case.""" + + self.check_markdown( + '!2', + '

!2

' # noqa: E501 + ) + + def test_default_discussion(self): + """Test default discussion case.""" + + self.check_markdown( + '?2', + '

?2

' # noqa: E501 + ) + + def test_default_commit(self): + """Test default commit case.""" + + self.check_markdown( + '3f6b07a8eeaa9d606115758d90f55fec565d4e2a', + '

3f6b07a

' # noqa: E501 + ) + + def test_default_compare(self): + """Test default compare case.""" + + self.check_markdown( + 'e2ed7e0b3973f3f9eb7a26b8ef7ae514eebfe0d2...90b6fb8711e75732f987982cc024e9bb0111beac', + '

e2ed7e0...90b6fb8

' # noqa: E501 + ) + + def test_user_link(self): + """Test user link.""" + + self.check_markdown( + 'http://test.com/facelessuser', + '

@facelessuser

' # noqa: E501 + ) + + def test_repo_link(self): + """Test repository link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions', + '

facelessuser/pymdown-extensions

' # noqa: E501 + ) + + def test_issue_link(self): + """Test issue link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions/issues/2', + '

#2

' # noqa: E501 + ) + + def test_pull_link(self): + """Test issue link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions/pull/2', + '

!2

' # noqa: E501 + ) + + def test_discussion_link(self): + """Test discussion link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions/discussions/2', + '

?2

' # noqa: E501 + ) + + def test_commit_link(self): + """Test commit link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions/commit/3f6b07a8eeaa9d606115758d90f55fec565d4e2a', + '

3f6b07a

' # noqa: E501 + ) + + def test_compare_link(self): + """Test compare link.""" + + self.check_markdown( + 'http://test.com/facelessuser/pymdown-extensions/compare/e2ed7e0b3973f3f9eb7a26b8ef7ae514eebfe0d2...90b6fb8711e75732f987982cc024e9bb0111beac', + '

e2ed7e0...90b6fb8

' # noqa: E501 + ) + + def test_external_user(self): + """Test external user in custom repo.""" + + self.check_markdown( + '@github:facelessuser', + '

@facelessuser

' # noqa: E501 + ) + + self.check_markdown( + '@test:facelessuser', + '

@facelessuser

' # noqa: E501 + ) + + def test_bad_name(self): + """Test bad name.""" + + extension = [ + 'pymdownx.magiclink', + 'pymdownx.saneheaders' + ] + + extension_configs = { + 'pymdownx.magiclink': { + 'repo_url_shorthand': True, + 'repo_url_shortener': True, + 'user': 'facelessuser', + 'repo': 'pymdown-extensions', + 'provider': 'bad-name', + 'custom': { + 'bad-name': { + 'host': 'http://bad.com', + 'label': 'Bad', + 'type': 'github' + } + } + } + } + + with self.assertRaises(ValueError): + markdown.markdown('', extensions=extension, extension_configs=extension_configs)