magiclink.py 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
  1. """
  2. Magic Link.
  3. pymdownx.magiclink
  4. An extension for Python Markdown.
  5. Find HTML, FTP links, and email address and turn them to actual links
  6. MIT license.
  7. Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
  8. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
  9. documentation files (the "Software"), to deal in the Software without restriction, including without limitation
  10. the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
  11. and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
  12. The above copyright notice and this permission notice shall be included in all copies or substantial portions
  13. of the Software.
  14. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
  15. TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
  16. THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
  17. CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
  18. DEALINGS IN THE SOFTWARE.
  19. """
  20. from markdown import Extension
  21. from markdown.treeprocessors import Treeprocessor
  22. from markdown import util as md_util
  23. import xml.etree.ElementTree as etree
  24. from . import util
  25. import re
  26. from markdown.inlinepatterns import LinkInlineProcessor, InlineProcessor
  27. MAGIC_LINK = 1
  28. MAGIC_AUTO_LINK = 2
  29. DEFAULT_EXCLUDES = {
  30. "bitbucket": ['dashboard', 'account', 'plans', 'support', 'repo'],
  31. "github": ['marketeplace', 'notifications', 'issues', 'pull', 'sponsors', 'settings', 'support'],
  32. "gitlab": ['dashboard', '-', 'explore', 'help', 'projects'],
  33. "twitter": ['i', 'messages', 'bookmarks', 'home']
  34. }
  35. # Bare link/email detection
  36. RE_MAIL = r'''(?xi)
  37. (?P<mail>
  38. (?<![-/\+@a-z\d_])(?:[-+a-z\d_]([-a-z\d_+]|\.(?!\.))*) # Local part
  39. (?<!\.)@(?:[-a-z\d_]+\.) # @domain part start
  40. (?:(?:[-a-z\d_]|(?<!\.)\.(?!\.))*)[a-z]\b # @domain.end (allow multiple dot names)
  41. (?![-@]) # Don't allow last char to be followed by these
  42. )
  43. '''
  44. RE_LINK = r'''(?xi)
  45. (?P<link>
  46. (?:(?<=\b)|(?<=_))(?:
  47. (?:ht|f)tps?://[^_\W][-\w]*(?:\.[-\w.]+)*| # (http|ftp)://
  48. (?P<www>w{3}\.)[^_\W][-\w]*(?:\.[-\w.]+)* # www.
  49. )
  50. /?[-\w.?,!'(){}\[\]/+&@%$#=:"|~;]* # url path, fragments, and query stuff
  51. (?:[^_\W]|[-/#@$+=]) # allowed end chars
  52. )
  53. '''
  54. RE_AUTOLINK = r'(?i)<((?:ht|f)tps?://[^<>]*)>'
  55. # Provider specific user regex rules
  56. RE_TWITTER_USER = r'\w{1,15}'
  57. RE_GITHUB_USER = r'[a-zA-Z\d](?:[-a-zA-Z\d_]{0,37}[a-zA-Z\d])?'
  58. RE_GITLAB_USER = r'[\.a-zA-Z\d_](?:[-a-zA-Z\d_\.]{0,37}[-a-zA-Z\d_])?'
  59. RE_BITBUCKET_USER = r'[-a-zA-Z\d_]{1,39}'
  60. # External mention patterns
  61. RE_ALL_EXT_MENTIONS = r'''(?x)
  62. (?P<mention>
  63. (?<![a-zA-Z])@
  64. (?:%s)
  65. )\b
  66. '''
  67. RE_TWITTER_EXT_MENTIONS = r'twitter:%s' % RE_TWITTER_USER
  68. RE_GITHUB_EXT_MENTIONS = r'github:%s' % RE_GITHUB_USER
  69. RE_GITLAB_EXT_MENTIONS = r'gitlab:%s' % RE_GITLAB_USER
  70. RE_BITBUCKET_EXT_MENTIONS = r'bitbucket:%s' % RE_BITBUCKET_USER
  71. # Internal mention patterns
  72. RE_INT_MENTIONS = r'(?P<mention>(?<![a-zA-Z])@%s)\b'
  73. # External repo mention patterns
  74. RE_GIT_EXT_REPO_MENTIONS = r'''(?x)
  75. (?P<mention>
  76. (?<![a-zA-Z])
  77. @(?:%s)
  78. )\b
  79. /(?P<mention_repo>[-._a-zA-Z\d]{0,99}[a-zA-Z\d])\b
  80. ''' % '|'.join([RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS, RE_BITBUCKET_EXT_MENTIONS])
  81. # Internal repo mention patterns
  82. RE_GIT_INT_REPO_MENTIONS = r'''(?x)
  83. (?P<mention>(?<![a-zA-Z])@%s)\b
  84. /(?P<mention_repo>[-._a-zA-Z\d]{0,99}[a-zA-Z\d])\b
  85. '''
  86. # External reference patterns (issue, pull request, commit, compare)
  87. RE_GIT_EXT_REFS = r'''(?x)
  88. (?P<all>(?<![@/])(?:(?P<user>\b%s)/)
  89. (?P<repo>\b[-._a-zA-Z\d]{0,99}[a-zA-Z\d])
  90. (?:(?P<issue>(?:\#|!)[1-9][0-9]*)|(?P<commit>@[a-f\d]{40})(?:\.{3}(?P<diff>[a-f\d]{40}))?))\b
  91. ''' % '|'.join([RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS, RE_BITBUCKET_EXT_MENTIONS])
  92. # Internal reference patterns (issue, pull request, commit, compare)
  93. RE_GIT_INT_EXT_REFS = r'''(?x)
  94. (?P<all>(?<![@/])(?:(?P<user>\b%s)/)?
  95. (?P<repo>\b[-._a-zA-Z\d]{0,99}[a-zA-Z\d])
  96. (?:(?P<issue>(?:\#|!)[1-9][0-9]*)|(?P<commit>@[a-f\d]{40})(?:\.{3}(?P<diff>[a-f\d]{40}))?))\b
  97. '''
  98. # Internal reference patterns for default user and repository (issue, pull request, commit, compare)
  99. RE_GIT_INT_MICRO_REFS = r'''(?x)
  100. (?P<all>
  101. (?:(?<![a-zA-Z])(?P<issue>(?:\#|!)[1-9][0-9]*)|(?P<commit>(?<![@/])\b[a-f\d]{40})(?:\.{3}(?P<diff>[a-f\d]{40}))?)
  102. )\b
  103. '''
  104. # Repository link shortening pattern
  105. RE_REPO_LINK = re.compile(
  106. r'''(?xi)
  107. ^(?:
  108. (?P<github>(?P<github_base>https://(?:w{{3}}\.)?github\.com/
  109. (?P<github_user_repo>(?P<github_user>{})/[^/]+))/
  110. (?:issues/(?P<github_issue>\d+)/?|
  111. pull/(?P<github_pull>\d+)/?|
  112. commit/(?P<github_commit>[\da-f]{{40}})/?|
  113. compare/(?P<github_diff1>[\da-f]{{40}})\.{{3}}
  114. (?P<github_diff2>[\da-f]{{40}})))|
  115. (?P<bitbucket>(?P<bitbucket_base>https://(?:w{{3}}\.)?bitbucket\.org/
  116. (?P<bitbucket_user_repo>(?P<bitbucket_user>{})/[^/]+))/
  117. (?:issues/(?P<bitbucket_issue>\d+)(?:/[^/]+)?/?|
  118. pull-requests/(?P<bitbucket_pull>\d+)(?:/[^/]+(?:/diff)?)?/?|
  119. commits/commit/(?P<bitbucket_commit>[\da-f]{{40}})/?|
  120. branches/commits/(?P<bitbucket_diff1>[\da-f]{{40}})
  121. (?:\.{{2}}|%0d)(?P<bitbucket_diff2>[\da-f]{{40}})\#diff))|
  122. (?P<gitlab>(?P<gitlab_base>https://(?:w{{3}}\.)?gitlab\.com/
  123. (?P<gitlab_user_repo>(?P<gitlab_user>{})/[^/]+))/
  124. (?:issues/(?P<gitlab_issue>\d+)/?|
  125. merge_requests/(?P<gitlab_pull>\d+)/?|
  126. commit/(?P<gitlab_commit>[\da-f]{{40}})/?|
  127. compare/(?P<gitlab_diff1>[\da-f]{{40}})\.{{3}}
  128. (?P<gitlab_diff2>[\da-f]{{40}})))
  129. )/?$
  130. '''.format(RE_GITHUB_USER, RE_BITBUCKET_USER, RE_GITLAB_USER)
  131. )
  132. # Repository link shortening pattern
  133. RE_USER_REPO_LINK = re.compile(
  134. r'''(?xi)
  135. ^(?:
  136. (?P<github>(?P<github_base>https://(?:w{{3}}\.)?github\.com/
  137. (?P<github_user_repo>(?P<github_user>{})(?:/(?P<github_repo>[^/]+))?))) |
  138. (?P<bitbucket>(?P<bitbucket_base>https://(?:w{{3}}\.)?bitbucket\.org/
  139. (?P<bitbucket_user_repo>(?P<bitbucket_user>{})(?:/(?P<bitbucket_repo>[^/]+)/?)?))) |
  140. (?P<gitlab>(?P<gitlab_base>https://(?:w{{3}}\.)?gitlab\.com/
  141. (?P<gitlab_user_repo>(?P<gitlab_user>{})(?:/(?P<gitlab_repo>[^/]+))?))) |
  142. )/?$
  143. '''.format(RE_GITHUB_USER, RE_BITBUCKET_USER, RE_GITLAB_USER)
  144. )
  145. RE_SOCIAL_LINK = re.compile(
  146. r'''(?xi)
  147. ^(?:
  148. (?P<twitter>(?P<twitter_base>https://(?:w{{3}}\.)?twitter\.com/(?P<twitter_user>{})))
  149. )/?$
  150. '''.format(RE_TWITTER_USER)
  151. )
  152. # Provider specific info (links, names, specific patterns, etc.)
  153. SOCIAL_PROVIDERS = {'twitter'}
  154. PROVIDER_INFO = {
  155. "twitter": {
  156. "provider": "Twitter",
  157. "url": "https://twitter.com",
  158. "user_pattern": RE_TWITTER_USER
  159. },
  160. "gitlab": {
  161. "provider": "GitLab",
  162. "url": "https://gitlab.com",
  163. "user_pattern": RE_GITLAB_USER,
  164. "issue": "https://gitlab.com/%s/%s/issues/%s",
  165. "pull": "https://gitlab.com/%s/%s/merge_requests/%s",
  166. "commit": "https://gitlab.com/%s/%s/commit/%s",
  167. "compare": "https://gitlab.com/%s/%s/compare/%s...%s",
  168. "hash_size": 8
  169. },
  170. "bitbucket": {
  171. "provider": "Bitbucket",
  172. "url": "https://bitbucket.org",
  173. "user_pattern": RE_BITBUCKET_USER,
  174. "issue": "https://bitbucket.org/%s/%s/issues/%s",
  175. "pull": "https://bitbucket.org/%s/%s/pull-requests/%s",
  176. "commit": "https://bitbucket.org/%s/%s/commits/commit/%s",
  177. "compare": "https://bitbucket.org/%s/%s/branches/commits/%s..%s#diff",
  178. "hash_size": 7
  179. },
  180. "github": {
  181. "provider": "GitHub",
  182. "url": "https://github.com",
  183. "user_pattern": RE_GITHUB_USER,
  184. "issue": "https://github.com/%s/%s/issues/%s",
  185. "pull": "https://github.com/%s/%s/pull/%s",
  186. "commit": "https://github.com/%s/%s/commit/%s",
  187. "compare": "https://github.com/%s/%s/compare/%s...%s",
  188. "hash_size": 7
  189. }
  190. }
  191. class _MagiclinkShorthandPattern(InlineProcessor):
  192. """Base shorthand link class."""
  193. def __init__(self, pattern, md, user, repo, provider, labels):
  194. """Initialize."""
  195. self.user = user
  196. self.repo = repo
  197. self.labels = labels
  198. self.provider = provider if provider in PROVIDER_INFO else ''
  199. InlineProcessor.__init__(self, pattern, md)
  200. class _MagiclinkReferencePattern(_MagiclinkShorthandPattern):
  201. """Convert #1, repo#1, user/repo#1, !1, repo!1, user/repo!1, hash, repo@hash, or user/repo@hash to links."""
  202. def process_issues(self, el, provider, user, repo, issue):
  203. """Process issues."""
  204. issue_type = issue[:1]
  205. issue_value = issue[1:]
  206. if issue_type == '#':
  207. issue_link = PROVIDER_INFO[provider]['issue']
  208. issue_label = self.labels.get('issue', 'Issue')
  209. class_name = 'magiclink-issue'
  210. else:
  211. issue_link = PROVIDER_INFO[provider]['pull']
  212. issue_label = self.labels.get('pull', 'Pull Request')
  213. class_name = 'magiclink-pull'
  214. if self.my_repo:
  215. text = '%s%s' % (issue_type, issue_value)
  216. elif self.my_user:
  217. text = '%s%s%s' % (repo, issue_type, issue_value)
  218. else:
  219. text = '%s/%s%s%s' % (user, repo, issue_type, issue_value)
  220. el.set('href', issue_link % (user, repo, issue_value))
  221. el.text = md_util.AtomicString(text)
  222. el.set('class', 'magiclink magiclink-%s %s' % (provider, class_name))
  223. el.set(
  224. 'title',
  225. '%s %s: %s/%s%s%s' % (
  226. PROVIDER_INFO[provider]['provider'],
  227. issue_label,
  228. user,
  229. repo,
  230. issue_type,
  231. issue_value
  232. )
  233. )
  234. def process_commit(self, el, provider, user, repo, commit):
  235. """Process commit."""
  236. hash_ref = commit[0:PROVIDER_INFO[provider]['hash_size']]
  237. if self.my_repo:
  238. text = hash_ref
  239. elif self.my_user:
  240. text = '%s@%s' % (repo, hash_ref)
  241. else:
  242. text = '%s/%s@%s' % (user, repo, hash_ref)
  243. el.set('href', PROVIDER_INFO[provider]['commit'] % (user, repo, commit))
  244. el.text = md_util.AtomicString(text)
  245. el.set('class', 'magiclink magiclink-%s magiclink-commit' % provider)
  246. el.set(
  247. 'title',
  248. '%s %s: %s/%s@%s' % (
  249. PROVIDER_INFO[provider]['provider'],
  250. self.labels.get('commit', 'Commit'),
  251. user,
  252. repo,
  253. hash_ref
  254. )
  255. )
  256. def process_compare(self, el, provider, user, repo, commit1, commit2):
  257. """Process commit."""
  258. hash_ref1 = commit1[0:PROVIDER_INFO[provider]['hash_size']]
  259. hash_ref2 = commit2[0:PROVIDER_INFO[provider]['hash_size']]
  260. if self.my_repo:
  261. text = '%s...%s' % (hash_ref1, hash_ref2)
  262. elif self.my_user:
  263. text = '%s@%s...%s' % (repo, hash_ref1, hash_ref2)
  264. else:
  265. text = '%s/%s@%s...%s' % (user, repo, hash_ref1, hash_ref2)
  266. el.set('href', PROVIDER_INFO[provider]['compare'] % (user, repo, commit1, commit2))
  267. el.text = md_util.AtomicString(text)
  268. el.set('class', 'magiclink magiclink-%s magiclink-compare' % provider)
  269. el.set(
  270. 'title',
  271. '%s %s: %s/%s@%s...%s' % (
  272. PROVIDER_INFO[provider]['provider'],
  273. self.labels.get('compare', 'Compare'),
  274. user,
  275. repo,
  276. hash_ref1,
  277. hash_ref2
  278. )
  279. )
  280. class MagicShortenerTreeprocessor(Treeprocessor):
  281. """Tree processor that finds repo issue and commit links and shortens them."""
  282. # Repo link types
  283. ISSUE = 0
  284. PULL = 1
  285. COMMIT = 2
  286. DIFF = 3
  287. REPO = 4
  288. USER = 5
  289. def __init__(self, md, base_url, base_user_url, labels, repo_shortner, social_shortener, excludes):
  290. """Initialize."""
  291. self.base = base_url
  292. self.repo_shortner = repo_shortner
  293. self.social_shortener = social_shortener
  294. self.base_user = base_user_url
  295. self.repo_labels = labels
  296. self.labels = {
  297. "github": "GitHub",
  298. "bitbucket": "Bitbucket",
  299. "gitlab": "GitLab"
  300. }
  301. self.excludes = excludes
  302. Treeprocessor.__init__(self, md)
  303. def shorten_repo(self, link, class_name, label, user_repo):
  304. """Shorten repo link."""
  305. text = '%s' % user_repo
  306. link.text = md_util.AtomicString(text)
  307. if 'magiclink-repository' not in class_name:
  308. class_name.append('magiclink-repository')
  309. link.set(
  310. 'title',
  311. "%s %s: %s" % (
  312. label, self.repo_labels.get('repository', 'Repository'), user_repo
  313. )
  314. )
  315. def shorten_user(self, link, class_name, label, user_repo):
  316. """Shorten user link."""
  317. link.text = md_util.AtomicString('@' + user_repo)
  318. if 'magiclink-mention' not in class_name:
  319. class_name.append('magiclink-mention')
  320. link.set(
  321. 'title',
  322. "%s %s: %s" % (
  323. label, self.repo_labels.get('metion', 'User'), user_repo
  324. )
  325. )
  326. def shorten_diff(self, link, class_name, label, user_repo, value, hash_size):
  327. """Shorten diff/compare links."""
  328. repo_label = self.repo_labels.get('compare', 'Compare')
  329. if self.my_repo:
  330. text = '%s...%s' % (value[0][0:hash_size], value[1][0:hash_size])
  331. elif self.my_user:
  332. text = '%s@%s...%s' % (user_repo.split('/')[1], value[0][0:hash_size], value[1][0:hash_size])
  333. else:
  334. text = '%s@%s...%s' % (user_repo, value[0][0:hash_size], value[1][0:hash_size])
  335. link.text = md_util.AtomicString(text)
  336. if 'magiclink-compare' not in class_name:
  337. class_name.append('magiclink-compare')
  338. link.set(
  339. 'title',
  340. '%s %s: %s@%s...%s' % (
  341. label, repo_label, user_repo.rstrip('/'), value[0][0:hash_size], value[1][0:hash_size]
  342. )
  343. )
  344. def shorten_commit(self, link, class_name, label, user_repo, value, hash_size):
  345. """Shorten commit link."""
  346. # user/repo@hash
  347. repo_label = self.repo_labels.get('commit', 'Commit')
  348. if self.my_repo:
  349. text = value[0:hash_size]
  350. elif self.my_user:
  351. text = '%s@%s' % (user_repo.split('/')[1], value[0:hash_size])
  352. else:
  353. text = '%s@%s' % (user_repo, value[0:hash_size])
  354. link.text = md_util.AtomicString(text)
  355. if 'magiclink-commit' not in class_name:
  356. class_name.append('magiclink-commit')
  357. link.set(
  358. 'title',
  359. '%s %s: %s@%s' % (label, repo_label, user_repo.rstrip('/'), value[0:hash_size])
  360. )
  361. def shorten_issue(self, link, class_name, label, user_repo, value, link_type):
  362. """Shorten issue/pull link."""
  363. # user/repo#(issue|pull)
  364. if link_type == self.ISSUE:
  365. issue_type = self.repo_labels.get('issue', 'Issue')
  366. separator = '#'
  367. if 'magiclink-issue' not in class_name:
  368. class_name.append('magiclink-issue')
  369. else:
  370. issue_type = self.repo_labels.get('pull', 'Pull Request')
  371. separator = '!'
  372. if 'magiclink-pull' not in class_name:
  373. class_name.append('magiclink-pull')
  374. if self.my_repo:
  375. text = separator + value
  376. elif self.my_user:
  377. text = user_repo.split('/')[1] + separator + value
  378. else:
  379. text = user_repo + separator + value
  380. link.text = md_util.AtomicString(text)
  381. link.set('title', '%s %s: %s%s%s' % (label, issue_type, user_repo.rstrip('/'), separator, value))
  382. def shorten_issue_commit(self, link, provider, link_type, user_repo, value, hash_size):
  383. """Shorten URL."""
  384. label = PROVIDER_INFO[provider]['provider']
  385. prov_class = 'magiclink-%s' % provider
  386. class_attr = link.get('class', '')
  387. class_name = class_attr.split(' ') if class_attr else []
  388. if 'magiclink' not in class_name:
  389. class_name.append('magiclink')
  390. if prov_class not in class_name:
  391. class_name.append(prov_class)
  392. # Link specific shortening logic
  393. if link_type is self.DIFF:
  394. self.shorten_diff(link, class_name, label, user_repo, value, hash_size)
  395. elif link_type is self.COMMIT:
  396. self.shorten_commit(link, class_name, label, user_repo, value, hash_size)
  397. else:
  398. self.shorten_issue(link, class_name, label, user_repo, value, link_type)
  399. link.set('class', ' '.join(class_name))
  400. def shorten_user_repo(self, link, provider, link_type, user_repo):
  401. """Shorten URL."""
  402. label = PROVIDER_INFO[provider]['provider']
  403. prov_class = 'magiclink-%s' % provider
  404. class_attr = link.get('class', '')
  405. class_name = class_attr.split(' ') if class_attr else []
  406. if 'magiclink' not in class_name:
  407. class_name.append('magiclink')
  408. if prov_class not in class_name:
  409. class_name.append(prov_class)
  410. # Link specific shortening logic
  411. if link_type is self.REPO:
  412. self.shorten_repo(link, class_name, label, user_repo)
  413. else:
  414. self.shorten_user(link, class_name, label, user_repo)
  415. link.set('class', ' '.join(class_name))
  416. def get_provider(self, match):
  417. """Get the provider and hash size."""
  418. # Set provider specific variables
  419. if match.group('github'):
  420. provider = 'github'
  421. elif match.group('bitbucket'):
  422. provider = 'bitbucket'
  423. elif match.group('gitlab'):
  424. provider = 'gitlab'
  425. return provider
  426. def get_social_provider(self, match):
  427. """Get social provider."""
  428. if match.group('twitter'):
  429. provider = 'twitter'
  430. return provider
  431. def get_type(self, provider, match):
  432. """Get the link type."""
  433. try:
  434. # Gather info about link type
  435. if match.group(provider + '_diff1') is not None:
  436. value = (match.group(provider + '_diff1'), match.group(provider + '_diff2'))
  437. link_type = self.DIFF
  438. elif match.group(provider + '_commit') is not None:
  439. value = match.group(provider + '_commit')
  440. link_type = self.COMMIT
  441. elif match.group(provider + '_pull') is not None:
  442. value = match.group(provider + '_pull')
  443. link_type = self.PULL
  444. else:
  445. value = match.group(provider + '_issue')
  446. link_type = self.ISSUE
  447. except IndexError:
  448. # Gather info about link type
  449. found = False
  450. try:
  451. if match.group(provider + '_repo') is not None:
  452. value = None
  453. link_type = self.REPO
  454. found = True
  455. except IndexError:
  456. pass
  457. if not found:
  458. value = None
  459. link_type = self.USER
  460. return value, link_type
  461. def is_my_repo(self, provider, match):
  462. """Check if link is from our specified user and repo."""
  463. # See if these links are from the specified repo.
  464. return self.base and match.group(provider + '_base') + '/' == self.base
  465. def is_my_user(self, provider, match):
  466. """Check if link is from our specified user."""
  467. return self.base_user and match.group(provider + '_base').startswith(self.base_user)
  468. def excluded(self, provider, match):
  469. """Check if user has been excluded."""
  470. user = match.group(provider + '_user')
  471. return user.lower() in self.excludes.get(provider, set())
  472. def run(self, root):
  473. """Shorten popular git repository links."""
  474. self.hide_protocol = self.config['hide_protocol']
  475. links = root.iter('a')
  476. for link in links:
  477. has_child = len(list(link))
  478. is_magic = link.attrib.get('magiclink')
  479. href = link.attrib.get('href', '')
  480. text = link.text
  481. found = False
  482. if is_magic:
  483. del link.attrib['magiclink']
  484. # We want a normal link. No sub-elements embedded in it, just a normal string.
  485. if has_child or not text: # pragma: no cover
  486. continue
  487. # Make sure the text matches the `href`. If needed, add back protocol to be sure.
  488. # Not all links will pass through MagicLink, so we try both with and without protocol.
  489. if (text == href or (is_magic and self.hide_protocol and ('https://' + text) == href)):
  490. if self.repo_shortner:
  491. m = RE_REPO_LINK.match(href)
  492. if m:
  493. provider = self.get_provider(m)
  494. self.my_repo = self.is_my_repo(provider, m)
  495. self.my_user = self.my_repo or self.is_my_user(provider, m)
  496. value, link_type = self.get_type(provider, m)
  497. found = True
  498. # All right, everything set, let's shorten.
  499. if not self.excluded(provider, m):
  500. self.shorten_issue_commit(
  501. link,
  502. provider,
  503. link_type,
  504. m.group(provider + '_user_repo'),
  505. value,
  506. PROVIDER_INFO[provider]['hash_size']
  507. )
  508. if not found and self.repo_shortner:
  509. m = RE_USER_REPO_LINK.match(href)
  510. if m:
  511. provider = self.get_provider(m)
  512. self.my_repo = self.is_my_repo(provider, m)
  513. self.my_user = self.my_repo or self.is_my_user(provider, m)
  514. value, link_type = self.get_type(provider, m)
  515. found = True
  516. if not self.excluded(provider, m):
  517. # All right, everything set, let's shorten.
  518. self.shorten_user_repo(
  519. link,
  520. provider,
  521. link_type,
  522. m.group(provider + '_user_repo')
  523. )
  524. if not found and self.social_shortener:
  525. m = RE_SOCIAL_LINK.match(href)
  526. if m:
  527. provider = self.get_social_provider(m)
  528. self.my_repo = self.is_my_repo(provider, m)
  529. self.my_user = self.my_repo or self.is_my_user(provider, m)
  530. value, link_type = self.get_type(provider, m)
  531. if not self.excluded(provider, m):
  532. # All right, everything set, let's shorten.
  533. self.shorten_user_repo(
  534. link,
  535. provider,
  536. link_type,
  537. m.group(provider + '_user')
  538. )
  539. return root
  540. class MagiclinkPattern(LinkInlineProcessor):
  541. """Convert html, ftp links to clickable links."""
  542. ANCESTOR_EXCLUDES = ('a',)
  543. def handleMatch(self, m, data):
  544. """Handle URL matches."""
  545. el = etree.Element("a")
  546. el.text = md_util.AtomicString(m.group('link'))
  547. if m.group("www"):
  548. href = "http://%s" % m.group('link')
  549. else:
  550. href = m.group('link')
  551. if self.config['hide_protocol']:
  552. el.text = md_util.AtomicString(el.text[el.text.find("://") + 3:])
  553. el.set("href", self.unescape(href.strip()))
  554. if self.config.get('repo_url_shortener', False):
  555. el.set('magiclink', str(MAGIC_LINK))
  556. return el, m.start(0), m.end(0)
  557. class MagiclinkAutoPattern(InlineProcessor):
  558. """Return a link Element given an auto link `<http://example/com>`."""
  559. def handleMatch(self, m, data):
  560. """Return link optionally without protocol."""
  561. el = etree.Element("a")
  562. el.set('href', self.unescape(m.group(1)))
  563. el.text = md_util.AtomicString(m.group(1))
  564. if self.config['hide_protocol']:
  565. el.text = md_util.AtomicString(el.text[el.text.find("://") + 3:])
  566. if self.config.get('repo_url_shortener', False):
  567. el.set('magiclink', str(MAGIC_AUTO_LINK))
  568. return el, m.start(0), m.end(0)
  569. class MagiclinkMailPattern(InlineProcessor):
  570. """Convert emails to clickable email links."""
  571. ANCESTOR_EXCLUDES = ('a',)
  572. def email_encode(self, code):
  573. """Return entity definition by code, or the code if not defined."""
  574. return "%s#%d;" % (md_util.AMP_SUBSTITUTE, code)
  575. def handleMatch(self, m, data):
  576. """Handle email link patterns."""
  577. el = etree.Element("a")
  578. email = self.unescape(m.group('mail'))
  579. href = "mailto:%s" % email
  580. el.text = md_util.AtomicString(''.join([self.email_encode(ord(c)) for c in email]))
  581. el.set("href", ''.join([md_util.AMP_SUBSTITUTE + '#%d;' % ord(c) for c in href]))
  582. return el, m.start(0), m.end(0)
  583. class MagiclinkMentionPattern(_MagiclinkShorthandPattern):
  584. """Convert @mention to links."""
  585. ANCESTOR_EXCLUDES = ('a',)
  586. def handleMatch(self, m, data):
  587. """Handle email link patterns."""
  588. text = m.group('mention')[1:]
  589. parts = text.split(':')
  590. if len(parts) > 1:
  591. provider = parts[0]
  592. mention = parts[1]
  593. else:
  594. provider = self.provider
  595. mention = parts[0]
  596. el = etree.Element("a")
  597. el.set('href', '%s/%s' % (PROVIDER_INFO[provider]['url'], mention))
  598. el.set(
  599. 'title',
  600. "%s %s: %s" % (PROVIDER_INFO[provider]['provider'], self.labels.get('mention', "User"), mention)
  601. )
  602. el.set('class', 'magiclink magiclink-%s magiclink-mention' % provider)
  603. el.text = md_util.AtomicString('@' + mention)
  604. return el, m.start(0), m.end(0)
  605. class MagiclinkRepositoryPattern(_MagiclinkShorthandPattern):
  606. """Convert @user/repo to links."""
  607. ANCESTOR_EXCLUDES = ('a',)
  608. def handleMatch(self, m, data):
  609. """Handle email link patterns."""
  610. text = m.group('mention')[1:]
  611. parts = text.split(':')
  612. if len(parts) > 1:
  613. provider = parts[0]
  614. user = parts[1]
  615. else:
  616. provider = self.provider
  617. user = parts[0]
  618. repo = m.group('mention_repo')
  619. el = etree.Element("a")
  620. el.set('href', '%s/%s/%s' % (PROVIDER_INFO[provider]['url'], user, repo))
  621. el.set(
  622. 'title',
  623. "%s %s: %s/%s" % (
  624. PROVIDER_INFO[provider]['provider'], self.labels.get('repository', 'Repository'), user, repo
  625. )
  626. )
  627. el.set('class', 'magiclink magiclink-%s magiclink-repository' % provider)
  628. el.text = md_util.AtomicString('%s/%s' % (user, repo))
  629. return el, m.start(0), m.end(0)
  630. class MagiclinkExternalRefsPattern(_MagiclinkReferencePattern):
  631. """Convert repo#1, user/repo#1, repo!1, user/repo!1, repo@hash, or user/repo@hash to links."""
  632. ANCESTOR_EXCLUDES = ('a',)
  633. def handleMatch(self, m, data):
  634. """Handle email link patterns."""
  635. is_commit = m.group('commit')
  636. is_diff = m.group('diff')
  637. value = m.group('commit')[1:] if is_commit else m.group('issue')
  638. value2 = m.group('diff') if is_diff else None
  639. repo = m.group('repo')
  640. user = m.group('user')
  641. if not user:
  642. user = self.user
  643. parts = user.split(':')
  644. if len(parts) > 1:
  645. provider = parts[0]
  646. user = parts[1]
  647. else:
  648. provider = self.provider
  649. # If there is no valid user or provider, reject
  650. if not user:
  651. return None, None, None
  652. self.my_user = user == self.user and provider == self.provider
  653. self.my_repo = self.my_user and repo == self.repo
  654. el = etree.Element("a")
  655. if is_diff:
  656. self.process_compare(el, provider, user, repo, value, value2)
  657. elif is_commit:
  658. self.process_commit(el, provider, user, repo, value)
  659. else:
  660. self.process_issues(el, provider, user, repo, value)
  661. return el, m.start(0), m.end(0)
  662. class MagiclinkInternalRefsPattern(_MagiclinkReferencePattern):
  663. """Convert #1, !1, and commit_hash."""
  664. ANCESTOR_EXCLUDES = ('a',)
  665. def handleMatch(self, m, data):
  666. """Handle email link patterns."""
  667. # We don't have a valid provider, user, and repo, reject
  668. if not self.user or not self.repo:
  669. return None, None, None
  670. is_commit = m.group('commit')
  671. is_diff = m.group('diff')
  672. value = m.group('commit') if is_commit else m.group('issue')
  673. value2 = m.group('diff') if is_diff else None
  674. repo = self.repo
  675. user = self.user
  676. provider = self.provider
  677. self.my_repo = True
  678. self.my_user = True
  679. el = etree.Element("a")
  680. if is_diff:
  681. self.process_compare(el, provider, user, repo, value, value2)
  682. elif is_commit:
  683. self.process_commit(el, provider, user, repo, value)
  684. else:
  685. self.process_issues(el, provider, user, repo, value)
  686. return el, m.start(0), m.end(0)
  687. class MagiclinkExtension(Extension):
  688. """Add auto link and link transformation extensions to Markdown class."""
  689. def __init__(self, *args, **kwargs):
  690. """Initialize."""
  691. self.config = {
  692. 'hide_protocol': [
  693. False,
  694. "If 'True', links are displayed without the initial ftp://, http:// or https://"
  695. "- Default: False"
  696. ],
  697. 'repo_url_shortener': [
  698. False,
  699. "If 'True' repo commit and issue links are shortened - Default: False"
  700. ],
  701. 'social_url_shortener': [
  702. False,
  703. "If 'True' social links are shortened - Default: False"
  704. ],
  705. 'shortener_user_exclude': [
  706. {
  707. "bitbucket": ['dashboard', 'account', 'plans', 'support', 'repo'],
  708. "github": ['marketeplace', 'notifications', 'issues', 'pull', 'sponsors', 'settings', 'support'],
  709. "gitlab": ['dashboard', '-', 'explore', 'help', 'projects'],
  710. "twitter": ['i', 'messages', 'bookmarks', 'home']
  711. },
  712. "A list of user names to exclude from URL shortening."
  713. ],
  714. 'repo_url_shorthand': [
  715. False,
  716. "If 'True' repo shorthand syntax is converted to links - Default: False"
  717. ],
  718. 'social_url_shorthand': [
  719. False,
  720. "If 'True' social shorthand syntax is converted to links - Default: False"
  721. ],
  722. 'provider': [
  723. 'github',
  724. 'The base provider to use (github, gitlab, bitbucket, twitter) - Default: "github"'
  725. ],
  726. 'labels': [
  727. {},
  728. "Title labels - Default: {}"
  729. ],
  730. 'user': [
  731. '',
  732. 'The base user name to use - Default: ""'
  733. ],
  734. 'repo': [
  735. '',
  736. 'The base repo to use - Default: ""'
  737. ]
  738. }
  739. super(MagiclinkExtension, self).__init__(*args, **kwargs)
  740. def setup_autolinks(self, md, config):
  741. """Setup auto links."""
  742. # Setup general link patterns
  743. auto_link_pattern = MagiclinkAutoPattern(RE_AUTOLINK, md)
  744. auto_link_pattern.config = config
  745. md.inlinePatterns.register(auto_link_pattern, "autolink", 120)
  746. link_pattern = MagiclinkPattern(RE_LINK, md)
  747. link_pattern.config = config
  748. md.inlinePatterns.register(link_pattern, "magic-link", 85)
  749. md.inlinePatterns.register(MagiclinkMailPattern(RE_MAIL, md), "magic-mail", 84.9)
  750. def setup_shorthand(self, md, int_mentions, ext_mentions, config):
  751. """Setup shorthand."""
  752. # Setup URL shortener
  753. escape_chars = ['@']
  754. util.escape_chars(md, escape_chars)
  755. # Repository shorthand
  756. if self.git_short:
  757. git_ext_repo = MagiclinkRepositoryPattern(
  758. RE_GIT_EXT_REPO_MENTIONS, md, self.user, self.repo, self.provider, self.labels
  759. )
  760. md.inlinePatterns.register(git_ext_repo, "magic-repo-ext-mention", 79.9)
  761. if not self.is_social:
  762. git_int_repo = MagiclinkRepositoryPattern(
  763. RE_GIT_INT_REPO_MENTIONS % int_mentions, md, self.user, self.repo, self.provider, self.labels
  764. )
  765. md.inlinePatterns.register(git_int_repo, "magic-repo-int-mention", 79.8)
  766. # Mentions
  767. pattern = RE_ALL_EXT_MENTIONS % '|'.join(ext_mentions)
  768. git_mention = MagiclinkMentionPattern(
  769. pattern, md, self.user, self.repo, self.provider, self.labels
  770. )
  771. md.inlinePatterns.register(git_mention, "magic-ext-mention", 79.7)
  772. git_mention = MagiclinkMentionPattern(
  773. RE_INT_MENTIONS % int_mentions, md, self.user, self.repo, self.provider, self.labels
  774. )
  775. md.inlinePatterns.register(git_mention, "magic-int-mention", 79.6)
  776. # Other project refs
  777. if self.git_short:
  778. git_ext_refs = MagiclinkExternalRefsPattern(
  779. RE_GIT_EXT_REFS, md, self.user, self.repo, self.provider, self.labels
  780. )
  781. md.inlinePatterns.register(git_ext_refs, "magic-ext-refs", 79.5)
  782. if not self.is_social:
  783. git_int_refs = MagiclinkExternalRefsPattern(
  784. RE_GIT_INT_EXT_REFS % int_mentions, md, self.user, self.repo, self.provider, self.labels
  785. )
  786. md.inlinePatterns.register(git_int_refs, "magic-int-refs", 79.4)
  787. git_int_micro_refs = MagiclinkInternalRefsPattern(
  788. RE_GIT_INT_MICRO_REFS, md, self.user, self.repo, self.provider, self.labels
  789. )
  790. md.inlinePatterns.register(git_int_micro_refs, "magic-int-micro-refs", 79.3)
  791. def setup_shortener(self, md, base_url, base_user_url, config, repo_shortner, social_shortener):
  792. """Setup shortener."""
  793. shortener = MagicShortenerTreeprocessor(
  794. md, base_url, base_user_url, self.labels, repo_shortner, social_shortener,
  795. self.shortener_exclusions
  796. )
  797. shortener.config = config
  798. md.treeprocessors.register(shortener, "magic-repo-shortener", 9.9)
  799. def get_base_urls(self, config):
  800. """Get base URLs."""
  801. base_url = ''
  802. base_user_url = ''
  803. if self.is_social:
  804. return base_url, base_user_url
  805. if self.user and self.repo:
  806. base_url = '%s/%s/%s/' % (PROVIDER_INFO[self.provider]['url'], self.user, self.repo)
  807. base_user_url = '%s/%s/' % (PROVIDER_INFO[self.provider]['url'], self.user)
  808. return base_url, base_user_url
  809. def extendMarkdown(self, md):
  810. """Add support for turning html links and emails to link tags."""
  811. config = self.getConfigs()
  812. # Setup repo variables
  813. self.user = config.get('user', '')
  814. self.repo = config.get('repo', '')
  815. self.provider = config.get('provider', 'github')
  816. self.labels = config.get('labels', {})
  817. self.is_social = self.provider in SOCIAL_PROVIDERS
  818. self.git_short = config.get('repo_url_shorthand', False)
  819. self.social_short = config.get('social_url_shorthand', False)
  820. self.repo_shortner = config.get('repo_url_shortener', False)
  821. self.social_shortener = config.get('social_url_shortener', False)
  822. self.shortener_exclusions = {k: set(v) for k, v in DEFAULT_EXCLUDES.items()}
  823. for key, value in config.get('shortener_user_exclude', {}).items():
  824. if key in ('github', 'bitbucket', 'gitlab', 'twitter') and isinstance(value, (list, tuple, set)):
  825. self.shortener_exclusions[key] = set([x.lower() for x in value])
  826. # Ensure valid provider
  827. if self.provider not in PROVIDER_INFO:
  828. self.provider = 'github'
  829. int_mentions = None
  830. ext_mentions = []
  831. if self.git_short:
  832. ext_mentions.extend([RE_BITBUCKET_EXT_MENTIONS, RE_GITHUB_EXT_MENTIONS, RE_GITLAB_EXT_MENTIONS])
  833. if self.social_short:
  834. ext_mentions.append(RE_TWITTER_EXT_MENTIONS)
  835. if self.git_short or self.social_short:
  836. int_mentions = PROVIDER_INFO[self.provider]['user_pattern']
  837. self.setup_autolinks(md, config)
  838. if self.git_short or self.social_short:
  839. self.setup_shorthand(md, int_mentions, ext_mentions, config)
  840. # Setup link post processor for shortening repository links
  841. if self.repo_shortner or self.social_shortener:
  842. base_url, base_user_url = self.get_base_urls(config)
  843. self.setup_shortener(md, base_url, base_user_url, config, self.repo_shortner, self.social_shortener)
  844. def makeExtension(*args, **kwargs):
  845. """Return extension."""
  846. return MagiclinkExtension(*args, **kwargs)