├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── mdx_linkify ├── __init__.py ├── mdx_linkify.py └── tests.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /build/ 3 | /.coverage 4 | /dist/ 5 | *.egg 6 | *.egg-info/ 7 | /include/ 8 | /lib/ 9 | *.py[cow] 10 | /share/ 11 | /.Python 12 | /pip-selfcheck.json 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.8" 5 | - "pypy3" 6 | install: 7 | - pip install coveralls 8 | script: 9 | - coverage run --source=mdx_linkify setup.py test 10 | after_success: 11 | - coveralls 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Raitis Stengrevics 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mdx Linkify 2 | 3 | [![Travis](https://img.shields.io/travis/daGrevis/mdx_linkify.svg)](https://travis-ci.org/daGrevis/mdx_linkify) 4 | [![Coveralls](https://img.shields.io/coveralls/daGrevis/mdx_linkify.svg)](https://coveralls.io/r/daGrevis/mdx_linkify?branch=master) 5 | [![PyPI](https://img.shields.io/pypi/v/mdx_linkify.svg)](https://pypi.python.org/pypi/mdx_linkify) 6 | [![PyPI](https://img.shields.io/pypi/pyversions/mdx_linkify.svg)](https://pypi.python.org/pypi/mdx_linkify) 7 | 8 | This extension for [Python Markdown](https://github.com/waylan/Python-Markdown) 9 | will convert text that look like links to HTML anchors. 10 | 11 | There's an alternative package that serves the same purpose called 12 | [`markdown-urlize`](https://github.com/r0wb0t/markdown-urlize). The main 13 | difference is that [`mdx_linkify`](https://github.com/daGrevis/mdx_linkify) is 14 | utilizing the excellent [`bleach`](https://github.com/jsocol/bleach) for 15 | searching links in text. :clap: 16 | 17 | ## Usage 18 | 19 | ### Minimal Example 20 | 21 | ```python 22 | from markdown import markdown 23 | 24 | markdown("minimal http://example.org/", extensions=["mdx_linkify"]) 25 | # Returns '

minimal http://example.org/

' 26 | ``` 27 | 28 | ## Installation 29 | 30 | The project is [on PyPI](https://pypi.python.org/pypi/mdx_linkify)! 31 | 32 | pip install mdx_linkify 33 | 34 | If you want the bleeding-edge version (this includes unreleased-to-PyPI code), 35 | you can always grab the master branch directly from Git. 36 | 37 | pip install git+git://github.com/daGrevis/mdx_linkify.git 38 | 39 | ### Configuring Linker 40 | 41 | To configure used Linker instance, use `linker_options` parameter. It will be passed to [`bleach.linkifier.Linker`](https://bleach.readthedocs.io/en/latest/linkify.html#using-bleach-linkifier-linker) unchanged. 42 | 43 | 44 | #### Example: Parse Emails 45 | 46 | ```python 47 | from mdx_linkify.mdx_linkify import LinkifyExtension 48 | from markdown import Markdown 49 | 50 | md = Markdown( 51 | extensions=[LinkifyExtension(linker_options={"parse_email": True})], 52 | ) 53 | 54 | assert md.convert('contact@example.com') == '

contact@example.com

' 55 | ``` 56 | 57 | #### Example: Custom TLDs 58 | 59 | ```python 60 | from mdx_linkify.mdx_linkify import LinkifyExtension 61 | from bleach.linkifier import build_url_re 62 | from markdown import Markdown 63 | 64 | md = Markdown( 65 | extensions=[LinkifyExtension(linker_options={"url_re": build_url_re(["custom", "custom2"])})], 66 | ) 67 | 68 | assert md.convert('linked.custom') == '

linked.custom

' 69 | ``` 70 | 71 | #### Example: Ignoring TLDs 72 | 73 | ```python 74 | from mdx_linkify.mdx_linkify import LinkifyExtension 75 | from markdown import Markdown 76 | 77 | def dont_linkify_net_tld(attrs, new=False): 78 | if attrs["_text"].endswith(".net"): 79 | return None 80 | 81 | return attrs 82 | 83 | md = Markdown( 84 | extensions=[LinkifyExtension(linker_options={"callbacks": [dont_linkify_net_tld]})], 85 | ) 86 | 87 | assert md.convert("not-linked.net") == '

not-linked.net

' 88 | ``` 89 | 90 | ## Development 91 | 92 | ``` 93 | git clone git@github.com:daGrevis/mdx_linkify.git 94 | virtualenv mdx_linkify/ 95 | cd mdx_linkify/ 96 | source bin/activate 97 | python setup.py install 98 | python setup.py test 99 | ``` 100 | 101 | Pull requests are much welcome! :+1: 102 | 103 | ## Releasing 104 | 105 | _(more like a cheatsheet for me actually)_ 106 | 107 | - Change version in `setup.py`, 108 | - Commit and tag it, 109 | - Push it (including tag), 110 | - Run `python setup.py register && python setup.py sdist upload`; 111 | -------------------------------------------------------------------------------- /mdx_linkify/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from mdx_linkify.mdx_linkify import makeExtension 4 | 5 | 6 | assert makeExtension # Silences pep8. 7 | -------------------------------------------------------------------------------- /mdx_linkify/mdx_linkify.py: -------------------------------------------------------------------------------- 1 | from bleach.linkifier import Linker 2 | 3 | from markdown.postprocessors import Postprocessor 4 | from markdown.extensions import Extension 5 | 6 | 7 | class LinkifyExtension(Extension): 8 | 9 | def __init__(self, **kwargs): 10 | self.config = { 11 | 'linker_options': [{}, 'Options for bleach.linkifier.Linker'], 12 | } 13 | super(LinkifyExtension, self).__init__(**kwargs) 14 | 15 | def extendMarkdown(self, md): 16 | md.postprocessors.register( 17 | LinkifyPostprocessor( 18 | md, 19 | self.getConfig('linker_options'), 20 | ), 21 | "linkify", 22 | 50, 23 | ) 24 | 25 | 26 | class LinkifyPostprocessor(Postprocessor): 27 | 28 | def __init__(self, md, linker_options): 29 | super(LinkifyPostprocessor, self).__init__(md) 30 | linker_options.setdefault("skip_tags", ["code"]) 31 | self._linker_options = linker_options 32 | 33 | def run(self, text): 34 | linker = Linker(**self._linker_options) 35 | return linker.linkify(text) 36 | 37 | 38 | def makeExtension(*args, **kwargs): 39 | return LinkifyExtension(*args, **kwargs) 40 | -------------------------------------------------------------------------------- /mdx_linkify/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import unittest 3 | 4 | from bleach.linkifier import build_url_re 5 | from markdown import markdown, Markdown 6 | 7 | from mdx_linkify.mdx_linkify import LinkifyExtension 8 | 9 | 10 | class LinkifyTest(unittest.TestCase): 11 | def test_link(self): 12 | expected = '

http://example.com

' 13 | actual = markdown("http://example.com", extensions=["mdx_linkify"]) 14 | self.assertEqual(expected, actual) 15 | 16 | def test_https_link(self): 17 | link = "https://example.com" 18 | expected = '

{link}

'.format(link=link) 19 | actual = markdown(link, extensions=["mdx_linkify"]) 20 | self.assertEqual(expected, actual) 21 | 22 | def test_complex_link(self): 23 | link = "http://spam.cheese.bacon.eggs.io/?monty=Python#im_loving_it" 24 | expected = '

{link}

'.format(link=link) 25 | actual = markdown(link, extensions=["mdx_linkify"]) 26 | self.assertEqual(expected, actual) 27 | 28 | def test_no_link(self): 29 | expected = '

foo.bar

' 30 | actual = markdown("foo.bar", extensions=["mdx_linkify"]) 31 | self.assertEqual(expected, actual) 32 | 33 | def test_links(self): 34 | expected = ('

http://example.com ' 35 | 'http://example.org

') 36 | actual = markdown("http://example.com http://example.org", 37 | extensions=["mdx_linkify"]) 38 | self.assertEqual(expected, actual) 39 | 40 | def test_links_with_text_between(self): 41 | expected = ('

http://example.com ' 42 | 'foo http://example.org' 43 | '

') 44 | actual = markdown("http://example.com foo http://example.org", 45 | extensions=["mdx_linkify"]) 46 | self.assertEqual(expected, actual) 47 | 48 | def test_existing_link(self): 49 | expected = '

http://example.com

' 50 | actual = markdown("[http://example.com](http://example.com)", 51 | extensions=["mdx_linkify"]) 52 | self.assertEqual(expected, actual) 53 | 54 | def test_backticks_link(self): 55 | expected = '

example.com

' 56 | actual = markdown("`example.com`", 57 | extensions=["mdx_linkify"]) 58 | self.assertEqual(expected, actual) 59 | 60 | def test_image_that_has_link_in_it(self): 61 | src = "http://example.com/monty.jpg" 62 | alt = "Monty" 63 | 64 | # Order is not guaranteed so we check for substring existence. 65 | actual = markdown("![Monty]({})".format(src), extensions=["mdx_linkify"]) 66 | self.assertIn(src, actual) 67 | self.assertIn(alt, actual) 68 | 69 | def test_no_escape(self): 70 | expected = '' 71 | actual = markdown(expected, extensions=["mdx_linkify"]) 72 | self.assertEqual(expected, actual) 73 | 74 | def test_no_schema(self): 75 | expected = '

example.com

' 76 | actual = markdown("example.com", extensions=["mdx_linkify"]) 77 | self.assertEqual(expected, actual) 78 | 79 | def test_email(self): 80 | expected = '

contact@example.com

' 81 | actual = markdown("contact@example.com", extensions=[ 82 | LinkifyExtension(linker_options={"parse_email": True}), 83 | ]) 84 | self.assertEqual(expected, actual) 85 | 86 | def test_no_email(self): 87 | expected = '

contact@example.com

' 88 | actual = markdown("contact@example.com", extensions=[ 89 | LinkifyExtension(linker_options={"parse_email": False}), 90 | ]) 91 | self.assertEqual(expected, actual) 92 | 93 | def test_callbacks(self): 94 | def dont_linkify_net_tld(attrs, new=False): 95 | if attrs["_text"].endswith(".net"): 96 | return None 97 | 98 | return attrs 99 | 100 | # assert expected behavior WITHOUT our callback 101 | actual = markdown("https://linked.net", 102 | extensions=["mdx_linkify"]) 103 | expected = '

https://linked.net

' 104 | self.assertEqual(actual, expected) 105 | 106 | md = Markdown( 107 | extensions=[ 108 | LinkifyExtension( 109 | linker_options={ 110 | "callbacks": [dont_linkify_net_tld], 111 | }, 112 | ), 113 | ], 114 | ) 115 | 116 | # assert .net no longer works 117 | actual = md.convert("https://not-linked.net") 118 | expected = '

https://not-linked.net

' 119 | self.assertEqual(actual, expected) 120 | 121 | # assert other links still work 122 | actual = md.convert("example.com") 123 | expected = '

example.com

' 124 | self.assertEqual(actual, expected) 125 | 126 | # assert that configuration parameters can be over-ridden at run time 127 | # https://python-markdown.github.io/extensions/api/#configsettings 128 | expected = '

https://should-be-linked.net

' 129 | actual = markdown("https://should-be-linked.net", extensions=["mdx_linkify"]) 130 | self.assertEqual(expected, actual) 131 | 132 | def test_custom_url_re(self): 133 | url_re = build_url_re(["example"]) 134 | expected = '

https://domain.example

' 135 | actual = markdown( 136 | "https://domain.example", 137 | extensions=[LinkifyExtension(linker_options={"url_re": url_re})], 138 | ) 139 | self.assertEqual(expected, actual) 140 | 141 | 142 | if __name__ == "__main__": 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path as path 2 | from setuptools import setup 3 | 4 | 5 | def get_readme(filename): 6 | if not path.exists(filename): 7 | return "" 8 | 9 | with open(path.join(path.dirname(__file__), filename)) as readme: 10 | content = readme.read() 11 | return content 12 | 13 | setup(name="mdx_linkify", 14 | version="2.1", 15 | author="Raitis (daGrevis) Stengrevics", 16 | author_email="dagrevis@gmail.com", 17 | description="Link recognition for Python Markdown", 18 | license="MIT", 19 | keywords="markdown links", 20 | url="https://github.com/daGrevis/mdx_linkify", 21 | packages=["mdx_linkify"], 22 | long_description=get_readme("README.md"), 23 | long_description_content_type="text/markdown", 24 | classifiers=[ 25 | "Topic :: Text Processing :: Markup", 26 | "Topic :: Utilities", 27 | "Programming Language :: Python :: 2.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: Implementation :: PyPy", 30 | "Development Status :: 4 - Beta", 31 | "License :: OSI Approved :: MIT License", 32 | ], 33 | install_requires=["Markdown>=3.0", "bleach>=3.1.0"], 34 | test_suite="mdx_linkify.tests") 35 | --------------------------------------------------------------------------------