├── .git-blame-ignore-revs ├── .github └── workflows │ ├── linting.yml │ └── push.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── django_markup ├── __init__.py ├── defaults.py ├── fields.py ├── filter │ ├── __init__.py │ ├── creole_filter.py │ ├── linebreaks_filter.py │ ├── markdown_filter.py │ ├── none_filter.py │ ├── rst_filter.py │ ├── smartypants_filter.py │ ├── textile_filter.py │ └── widont_filter.py ├── markup.py ├── templatetags │ ├── __init__.py │ └── markup_tags.py └── tests │ ├── __init__.py │ ├── files │ ├── rst_header.txt │ ├── rst_header_expected.txt │ ├── rst_raw.txt │ ├── rst_with_pygments.txt │ └── rst_with_pygments_expected.txt │ ├── markup_strings.py │ ├── settings.py │ ├── templates │ ├── test_templatetag.html │ └── test_templatetag_filterwrapper.html │ ├── test_custom_filter.py │ ├── test_filter.py │ └── test_templatetag.py ├── docs ├── _static │ ├── basic.css │ ├── default.css │ └── pygments.css ├── bundled_filter │ ├── creole.rst │ ├── linebreaks.rst │ ├── markdown.rst │ ├── none.rst │ ├── rst.rst │ ├── smartypants.rst │ ├── textile.rst │ └── widont.rst ├── conf.py ├── configuration.rst ├── filter.rst ├── filter_settings.rst ├── formatter.rst ├── index.rst ├── installation.rst ├── usage_models.rst ├── usage_python.rst └── usage_templates.rst ├── pyproject.toml └── tox.ini /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Black the entire project 2 | af5c4b8d50ca9aa512e1cf6af5bab4cbaaa2898a 3 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality Checks 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | linting: 8 | name: Code Quality Checks 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Checkout Code 12 | uses: actions/checkout@v4 13 | 14 | - name: Install Dependencies 15 | run: pip install "ruff" "mypy==1.5.1" 16 | 17 | - name: Code Linting 18 | run: ruff check django_markup 19 | 20 | - name: Code Formatting 21 | run: ruff format --check django_markup 22 | 23 | - name: Type Check 24 | run: mypy --install-types --non-interactive django_markup 25 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Testsuite Run 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox tox-gh-actions 25 | - name: Test with tox 26 | run: tox 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .coverage 4 | .eggs 5 | build/ 6 | dist/ 7 | docs/_build 8 | poetry.lock -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.9.1 (2024-11-19): 4 | 5 | - Drop support for Python <= 3.8. 6 | - Drop support for Django <= 4.1. 7 | - Added support for Python 3.13. 8 | 9 | ## v1.9 (2024-08-11): 10 | 11 | - Minor Type Annotation fixes. 12 | - Switch from pipenv to Poetry. 13 | - Added support for Django 5.1. 14 | 15 | ## v1.8.1 (2023-10-07): 16 | 17 | - Remove all annotations for "Self". It would require an additional dependency for 18 | installations on Python <3.11 and that's not worth it. 19 | 20 | ## v1.8 (2023-10-07): 21 | 22 | - Drop support for Python <3.8. 23 | - Added support for Python 3.12 and Django 5.0. 24 | - Type Annotations. 25 | 26 | ## v1.7.2 (2023-05-01): 27 | 28 | - Fixed a setup.cfg bug that defined the minimal Django version to be v3.7 which does 29 | not exist. The correct version is 3.2. 30 | 31 | ## v1.7.1 (2023-04-25): 32 | 33 | - Fixed Python classifiers in setup.cfg. 34 | 35 | ## v1.7 (2023-04-25): 36 | 37 | - Django 4.2 compatibility and tests. 38 | - Python 3.11 compatibility and tests. 39 | 40 | ## v1.6 (2022-08-14): 41 | 42 | - Dropped support for Django <3.2 and Python <3.7. 43 | - Django 3.2 (LTS) compatibility and tests. 44 | - Django 4.0 compatibility and tests. 45 | - Django 4.1 compatibility and tests. 46 | - Python 3.9 compatibility and tests. 47 | - Python 3.10 compatibility and tests. 48 | 49 | 50 | ## v1.5 (2020-06-12): 51 | 52 | - Dropped support for Django <=1.11 and Python <=3.5. 53 | - Python 3.8 compatibility and tests. 54 | - Django 3.0 compatibility and tests. 55 | - bleach-whitelist dependency is no longer necessary as tags are now shipped 56 | with the built-in markdown filter. 57 | - Uses pytest for testing. 58 | 59 | ## v1.4 (2019-03-15): 60 | 61 | - Markdown's safe_mode was deprecated and no longer functional, it's behavior 62 | was replaced with [bleach]. 63 | - Pipfile support for local development and general code cleanup. 64 | 65 | [bleach]: https://github.com/mozilla/bleach 66 | 67 | ## v1.3 (2018-09-07): 68 | 69 | - Python 3.6 and 3.7 compatibility and tests. 70 | - Django 2.0 and 2.1 compatibility and tests. 71 | - The package setup script now provides the ability to install all filter 72 | dependencies automatically. See the installation Readme for details. 73 | 74 | ## v1.2 (2017-03-18): 75 | 76 | - Django 1.10 compatibility and tests. 77 | - Updated all filter dependencies. most notably SmartyPants to v2.0 78 | which changed it's API, so your project dependencies need to update it 79 | as well. 80 | 81 | ## v1.1 (2016-05-02): 82 | 83 | - The Markdown filter has the ``safe_mode`` option enabled by default. 84 | - The RestructuredText filter has the file and raw content inclusion 85 | disabled by default. 86 | 87 | ## v1.0 (2016-01-02): 88 | 89 | - Removed some 5 year old dust 90 | - Django 1.8+ compatible 91 | - Tests 92 | 93 | Backwards incompatible changes: 94 | 95 | - Removed Pygments highlighting in the Markdown and RestructuredText filter. 96 | - Removed CreoleParser library in favor of a pypi package. 97 | - Removed Lightbox filter. 98 | - The RestructuredText filter now renders level 1 and 2 headers. 99 | See Github [Issue 14] for details and a backwards compatible workaround. 100 | 101 | ## v0.4 (2011-06-01): 102 | 103 | - Added a widont filter 104 | - MarkupField is South compatible. 105 | - Tested with Django 1.3 106 | 107 | ## v0.3 (2009-07-29): 108 | 109 | django-markup now ships with a builtin creole parser. Advantage is, that 110 | the recently used Creoleparser library needs the Genshi lib, which needs 111 | a c-compiler and so on. The builtin creole parser is a pure python library 112 | without any dependencies and follows the wikicreole.org specifications. 113 | django-markup uses the [WikiCreole library]. 114 | 115 | [WikiCreole library]: http://devel.sheep.art.pl/creole/ 116 | [Issue 14]: https://github.com/bartTC/django-markup/issues/14 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009, Martin Mahner 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name django-markup nor the names of its contributors 13 | may be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 23 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 📖 Full documentation on https://django-markup.readthedocs.io/en/latest/ 4 | 5 | # django-markup 6 | 7 | This app is a generic way to provide filters that convert text into html. 8 | 9 | ## Compatibility Matrix: 10 | 11 | | Py/Dj | 3.9 | 3.10 | 3.11 | 3.12 | 3.13 | 12 | |-----------|-----|------|------|------|------| 13 | | 4.2 (LTS) | ✓ | ✓ | ✓ | ✓ | ✓ | 14 | | 5.0 | — | ✓ | ✓ | ✓ | ✓ | 15 | | 5.1 | — | ✓ | ✓ | ✓ | ✓ | 16 | 17 | ## Quickstart 18 | 19 | Download and install the package from the python package index (pypi): 20 | 21 | Note that `django-markup` ships with some filters ready to use, but the more 22 | complex packages such as Markdown or ReStructuredText are not part of the code. 23 | Please refer the docs which packages are used for the built-in filter. 24 | 25 | An alternative is to install django-markup with all filter dependencies 26 | right away. Do so with: 27 | 28 | $ pip install django-markup[all_filter_dependencies] 29 | 30 | Then add it to the ``INSTALLED_APPS`` list: 31 | 32 | INSTALLED_APPS = ( 33 | ... 34 | 'django_markup', 35 | ) 36 | 37 | Use it in the template: 38 | 39 | {% load markup_tags %} 40 | {{ the_text|apply_markup:"markdown" }} 41 | 42 | Or in Python code: 43 | 44 | from django_markup.markup import formatter 45 | formatter('Some *Markdown* text.', filter_name='markdown') 46 | 47 | # Testsuite 48 | 49 | To run the testsuite install the project with pipenv and run it: 50 | 51 | % pipenv install --dev 52 | $ pipenv run test 53 | 54 | You can also test against a variation of Django and Python versions 55 | using tox: 56 | 57 | $ tox 58 | 59 | -------------------------------------------------------------------------------- /django_markup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartTC/django-markup/7c94ca809fcfd16c532c76b8089140100c8a5cfd/django_markup/__init__.py -------------------------------------------------------------------------------- /django_markup/defaults.py: -------------------------------------------------------------------------------- 1 | # The list of automatically loaded MarkupFilters 2 | from django_markup.filter.creole_filter import CreoleMarkupFilter 3 | from django_markup.filter.linebreaks_filter import LinebreaksMarkupFilter 4 | from django_markup.filter.markdown_filter import MarkdownMarkupFilter 5 | from django_markup.filter.none_filter import NoneMarkupFilter 6 | from django_markup.filter.rst_filter import RstMarkupFilter 7 | from django_markup.filter.smartypants_filter import SmartyPantsMarkupFilter 8 | from django_markup.filter.textile_filter import TextileMarkupFilter 9 | from django_markup.filter.widont_filter import WidontMarkupFilter 10 | 11 | # MarkupFilter that get's loaded automatically 12 | # You can override this list within your settings: MARKUP_FILTER 13 | 14 | DEFAULT_MARKUP_FILTER = { 15 | "creole": CreoleMarkupFilter, 16 | "linebreaks": LinebreaksMarkupFilter, 17 | "markdown": MarkdownMarkupFilter, 18 | "none": NoneMarkupFilter, 19 | "restructuredtext": RstMarkupFilter, 20 | "smartypants": SmartyPantsMarkupFilter, 21 | "textile": TextileMarkupFilter, 22 | "widont": WidontMarkupFilter, 23 | } 24 | 25 | # MarkupFilter that are the default value for choices, used in the MarkupField 26 | # You can override this list within your settings: MARKUP_CHOICES 27 | 28 | DEFAULT_MARKUP_CHOICES = ("none", "linebreaks", "markdown", "restructuredtext") 29 | -------------------------------------------------------------------------------- /django_markup/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from django.db.models.fields import CharField 6 | from django.utils.translation import gettext_lazy 7 | 8 | from django_markup.markup import MarkupFormatter, UnregisteredFilterError, formatter 9 | 10 | 11 | class MarkupField(CharField): 12 | """ 13 | A CharField that holds the markup name for the row. In the admin it's 14 | displayed as a ChoiceField. 15 | """ 16 | 17 | def __init__( 18 | self, 19 | default: str | None = None, 20 | formatter: MarkupFormatter = formatter, 21 | *args: Any, 22 | **kwargs: Any, 23 | ) -> None: 24 | # Check that the default value is a valid filter 25 | if default: 26 | if default not in formatter.filter_list: 27 | msg = ( 28 | f"'{default}' is not a registered markup filter. " 29 | f"Registered filters are: {formatter.registered_filter_names}." 30 | ) 31 | raise UnregisteredFilterError(msg) 32 | kwargs.setdefault("default", default) 33 | 34 | kwargs.setdefault("max_length", 255) 35 | kwargs.setdefault("choices", formatter.choices()) 36 | kwargs.setdefault("verbose_name", gettext_lazy("markup")) 37 | CharField.__init__(self, *args, **kwargs) 38 | -------------------------------------------------------------------------------- /django_markup/filter/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class MarkupFilter: 5 | """ 6 | Abstract your new filters from this class. This is the most simplest way of 7 | a filter, it accepts the text in it's render method and returns it, as is. 8 | """ 9 | 10 | title = "BaseFilter" 11 | 12 | def render( 13 | self, 14 | text: str, 15 | **kwargs: Any, # Unused argument 16 | ) -> str: 17 | return text 18 | -------------------------------------------------------------------------------- /django_markup/filter/creole_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class CreoleMarkupFilter(MarkupFilter): 7 | title = "Creole (Wiki Syntax)" 8 | 9 | def render( 10 | self, 11 | text: str, 12 | **kwargs: Any, # Unused argument 13 | ) -> str: 14 | from creole import creole2html 15 | 16 | return creole2html(text) 17 | -------------------------------------------------------------------------------- /django_markup/filter/linebreaks_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.template.defaultfilters import linebreaks 4 | 5 | from django_markup.filter import MarkupFilter 6 | 7 | 8 | class LinebreaksMarkupFilter(MarkupFilter): 9 | """ 10 | Replaces line breaks in plain text with appropriate HTML; a single 11 | newline becomes an HTML line break (``
``) and a new line 12 | followed by a blank line becomes a paragraph break (``

``). 13 | """ 14 | 15 | title = "Linebreaks" 16 | 17 | def render( 18 | self, 19 | text: str, 20 | **kwargs: Any, # Unused argument 21 | ) -> str: 22 | return linebreaks(text, **kwargs) 23 | -------------------------------------------------------------------------------- /django_markup/filter/markdown_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class MarkdownMarkupFilter(MarkupFilter): 7 | """ 8 | Applies Markdown conversion to a string, and returns the HTML. 9 | """ 10 | 11 | title = "Markdown" 12 | kwargs: ClassVar = {"safe_mode": True} 13 | 14 | def render( 15 | self, 16 | text: str, 17 | **kwargs: Any, # Unused argument 18 | ) -> str: 19 | if kwargs: 20 | self.kwargs.update(kwargs) 21 | 22 | from markdown import markdown 23 | 24 | text = markdown(text, **self.kwargs) 25 | 26 | # Markdown's safe_mode is deprecated. We replace it with Bleach 27 | # to keep it backwards compatible. 28 | # Https://python-markdown.github.io/change_log/release-2.6/#safe_mode-deprecated 29 | if self.kwargs.get("safe_mode") is True: 30 | from bleach import clean 31 | 32 | # fmt: off 33 | markdown_tags = [ 34 | "h1", "h2", "h3", "h4", "h5", "h6", 35 | "b", "i", "strong", "em", "tt", 36 | "p", "br", 37 | "span", "div", "blockquote", "pre", "code", "hr", 38 | "ul", "ol", "li", "dd", "dt", 39 | "img", 40 | "a", 41 | "sub", "sup", 42 | ] 43 | 44 | markdown_attrs = { 45 | "*": ["id"], 46 | "img": ["src", "alt", "title"], 47 | "a": ["href", "alt", "title"], 48 | } 49 | # fmt: on 50 | 51 | text = clean(text, markdown_tags, markdown_attrs) 52 | 53 | return text 54 | -------------------------------------------------------------------------------- /django_markup/filter/none_filter.py: -------------------------------------------------------------------------------- 1 | from django_markup.filter import MarkupFilter 2 | 3 | 4 | class NoneMarkupFilter(MarkupFilter): 5 | """ 6 | Simply returns the text without any modification. This is the same as the 7 | base class does. 8 | """ 9 | 10 | title = "None (no processing)" 11 | -------------------------------------------------------------------------------- /django_markup/filter/rst_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class RstMarkupFilter(MarkupFilter): 7 | """ 8 | Converts a reStructuredText string to HTML. If the pygments library is 9 | installed, you can use a special `sourcecode` directive to highlight 10 | portions of your text. Example: 11 | 12 | .. sourcecode: python 13 | 14 | def foo(): 15 | return 'foo' 16 | """ 17 | 18 | title = "reStructuredText" 19 | rst_part_name = "html_body" 20 | kwargs: ClassVar = { 21 | "settings_overrides": { 22 | "raw_enabled": False, 23 | "file_insertion_enabled": False, 24 | }, 25 | } 26 | 27 | def render( 28 | self, 29 | text: str, 30 | **kwargs: Any, # Unused argument 31 | ) -> str: 32 | if kwargs: 33 | self.kwargs.update(kwargs) 34 | from docutils import core 35 | 36 | publish_args = {"source": text, "writer_name": "html4css1"} 37 | publish_args.update(**self.kwargs) 38 | parts = core.publish_parts(**publish_args) 39 | return parts[self.rst_part_name] 40 | -------------------------------------------------------------------------------- /django_markup/filter/smartypants_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class SmartyPantsMarkupFilter(MarkupFilter): 7 | title = "SmartyPants" 8 | 9 | def render( 10 | self, 11 | text: str, 12 | **kwargs: Any, # Unused argument 13 | ) -> str: 14 | from smartypants import smartypants 15 | 16 | return smartypants(text, **kwargs) 17 | -------------------------------------------------------------------------------- /django_markup/filter/textile_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class TextileMarkupFilter(MarkupFilter): 7 | title = "Textile" 8 | 9 | def render( 10 | self, 11 | text: str, 12 | **kwargs: Any, # Unused argument 13 | ) -> str: 14 | from textile import textile 15 | 16 | return textile(text, **kwargs) 17 | -------------------------------------------------------------------------------- /django_markup/filter/widont_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django_markup.filter import MarkupFilter 4 | 5 | 6 | class WidontMarkupFilter(MarkupFilter): 7 | title = "Widont" 8 | 9 | def render( 10 | self, 11 | text: str, 12 | **kwargs: Any, # Unused argument 13 | ) -> str: 14 | return " ".join(text.strip().rsplit(" ", 1)) 15 | -------------------------------------------------------------------------------- /django_markup/markup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from django.conf import settings 6 | 7 | from django_markup.defaults import DEFAULT_MARKUP_CHOICES, DEFAULT_MARKUP_FILTER 8 | 9 | if TYPE_CHECKING: 10 | from django_markup.filter import MarkupFilter 11 | 12 | 13 | class UnregisteredFilterError(ValueError): 14 | pass 15 | 16 | 17 | class MarkupFormatter: 18 | filter_list: dict[str, type[MarkupFilter]] = {} 19 | 20 | def __init__(self, load_defaults: bool = True) -> None: 21 | if load_defaults: 22 | filter_list = getattr(settings, "MARKUP_FILTER", DEFAULT_MARKUP_FILTER) 23 | for filter_name, filter_class in filter_list.items(): 24 | self.register(filter_name, filter_class) 25 | 26 | def _get_filter_title(self, filter_name: str) -> str: 27 | """ 28 | Returns the human-readable title of a given filter_name. 29 | 30 | If no title attribute is set, the filter_name is used, where underscores are 31 | replaced with whitespaces and the first character of each word is uppercased. 32 | 33 | Example: 34 | 35 | >>> MarkupFormatter._get_title('markdown') 36 | 'Markdown' 37 | 38 | >>> MarkupFormatter._get_title('a_cool_filter_name') 39 | 'A Cool Filter Name' 40 | """ 41 | title = getattr(self.filter_list[filter_name], "title", None) 42 | if not title: 43 | title = " ".join([w.title() for w in filter_name.split("_")]) 44 | return title 45 | 46 | @property 47 | def registered_filter_names(self) -> list[str]: 48 | return list(self.filter_list.keys()) 49 | 50 | def choices(self) -> list[tuple[str, str]]: 51 | """ 52 | Returns the filter list as a tuple. Useful for model choices. 53 | """ 54 | choice_list = getattr(settings, "MARKUP_CHOICES", DEFAULT_MARKUP_CHOICES) 55 | return [(f, self._get_filter_title(f)) for f in choice_list] 56 | 57 | def register( 58 | self, 59 | filter_name: str, 60 | filter_class: type[MarkupFilter], 61 | ) -> None: 62 | """ 63 | Register a new filter for use 64 | """ 65 | self.filter_list[filter_name] = filter_class 66 | 67 | def update( 68 | self, 69 | filter_name: str, 70 | filter_class: type[MarkupFilter], 71 | ) -> None: 72 | """ 73 | Yep, this is the same as register, it just sounds better. 74 | """ 75 | self.filter_list[filter_name] = filter_class 76 | 77 | def unregister(self, filter_name: str) -> None: 78 | """ 79 | Unregister a filter from the filter list 80 | """ 81 | if filter_name in self.filter_list: 82 | self.filter_list.pop(filter_name) 83 | 84 | def flush(self) -> None: 85 | """ 86 | Flushes the filter list. 87 | """ 88 | self.filter_list = {} 89 | 90 | def __call__( 91 | self, 92 | text: str, 93 | filter_name: str | None = None, 94 | **kwargs: Any, 95 | ) -> str: 96 | """ 97 | Applies text-to-HTML conversion to a string, and returns the 98 | HTML. 99 | 100 | TODO: `filter` should either be a filter_name or a filter class. 101 | """ 102 | 103 | filter_fallback = getattr(settings, "MARKUP_FILTER_FALLBACK", None) 104 | if not filter_name and filter_fallback: 105 | filter_name = filter_fallback 106 | 107 | # Check that the filter_name is a registered markup filter 108 | if filter_name not in self.filter_list: 109 | msg = ( 110 | f"'{filter_name}' is not a registered markup filter. " 111 | f"Registered filters are: {formatter.registered_filter_names}." 112 | ) 113 | raise UnregisteredFilterError(msg) 114 | filter_class = self.filter_list[filter_name] 115 | 116 | # Read global filter settings and apply it 117 | filter_kwargs = {} 118 | filter_settings = getattr(settings, "MARKUP_SETTINGS", {}) 119 | if filter_name in filter_settings: 120 | filter_kwargs.update(filter_settings[filter_name]) 121 | filter_kwargs.update(**kwargs) 122 | 123 | # Apply the filter on text 124 | return filter_class().render(text, **filter_kwargs) 125 | 126 | 127 | # Unless you need to have multiple instances of MarkupFormatter lying 128 | # around, or want to subclass it, the easiest way to use it is to 129 | # import this instance. 130 | # 131 | # Note if you create a new instance of MarkupFormatter(), the built 132 | # in filters are not assigned. 133 | 134 | formatter = MarkupFormatter() 135 | -------------------------------------------------------------------------------- /django_markup/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartTC/django-markup/7c94ca809fcfd16c532c76b8089140100c8a5cfd/django_markup/templatetags/__init__.py -------------------------------------------------------------------------------- /django_markup/templatetags/markup_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.utils.safestring import mark_safe 3 | 4 | from django_markup.markup import formatter 5 | 6 | register = Library() 7 | 8 | 9 | @register.filter 10 | def apply_markup(text: str, filter_name: str) -> str: 11 | return mark_safe(formatter(text, filter_name)) # noqa: S308 12 | -------------------------------------------------------------------------------- /django_markup/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartTC/django-markup/7c94ca809fcfd16c532c76b8089140100c8a5cfd/django_markup/tests/__init__.py -------------------------------------------------------------------------------- /django_markup/tests/files/rst_header.txt: -------------------------------------------------------------------------------- 1 | ******** 2 | Header 1 3 | ******** 4 | 5 | Header 2 6 | ======== 7 | 8 | Header 3 9 | -------- 10 | 11 | Here is some text. 12 | -------------------------------------------------------------------------------- /django_markup/tests/files/rst_header_expected.txt: -------------------------------------------------------------------------------- 1 |
2 |

Header 1

3 |

Header 2

4 |
5 |

Header 3

6 |

Here is some text.

7 |
8 |
9 | -------------------------------------------------------------------------------- /django_markup/tests/files/rst_raw.txt: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 | 4 | 5 | Other text 6 | -------------------------------------------------------------------------------- /django_markup/tests/files/rst_with_pygments.txt: -------------------------------------------------------------------------------- 1 | Some **rST** text. 2 | 3 | .. code-block:: python 4 | 5 | def test(): 6 | return 'Hello World' 7 | -------------------------------------------------------------------------------- /django_markup/tests/files/rst_with_pygments_expected.txt: -------------------------------------------------------------------------------- 1 |
2 |

Some rST text.

3 |
4 | def test():
5 |   return 'Hello World'
6 | 
7 |
8 | -------------------------------------------------------------------------------- /django_markup/tests/markup_strings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sample Markup strings and their expected pendant. 3 | """ 4 | 5 | NONE = ("*This* is some text.", "*This* is some text.") 6 | 7 | # Django's linebreaks filter 8 | LINEBREAKS = ("*This* is some text.", "

*This* is some text.

") 9 | 10 | # Simple Markdown 11 | MARKDOWN = ("*This* is some text.", "

This is some text.

") 12 | 13 | # Markdown with PRE tag 14 | MARKDOWN_PRE = ( 15 | " code line 1\n code line 2\n", 16 | "
code line 1\ncode line 2\n
", 17 | ) 18 | 19 | # Simple Markdown 20 | MARKDOWN_JS_LINK = ( 21 | '[Javascript Link](javascript:alert("123");)', 22 | '

Javascript Link;)

', 23 | ) 24 | 25 | # Simple Textile 26 | TEXTILE = ( 27 | "*This* is some text.", 28 | "\t

This is some text.

", 29 | ) 30 | 31 | # Simple RestructuredText 32 | RST = ( 33 | "*This* is some text.", 34 | '
\n

This is some text.

\n
\n', 35 | ) 36 | 37 | # Creole Sntax 38 | CREOLE = ( 39 | "This is **some //text//**.", 40 | "

This is some text.

", 41 | ) 42 | # Smartypants 43 | SMARTYPANTS = ('This is "some" text.', "This is “some” text.") 44 | 45 | # Windont 46 | WIDONT = ( 47 | "Widont does not leave anyone alone.", 48 | "Widont does not leave anyone alone.", 49 | ) 50 | -------------------------------------------------------------------------------- /django_markup/tests/settings.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from pathlib import Path 4 | 5 | USE_TZ = False 6 | 7 | SECRET_KEY = "".join(random.sample(string.printable, 20)) 8 | 9 | DATABASES = { 10 | "default": { 11 | "ENGINE": "django.db.backends.sqlite3", 12 | "NAME": "dev.db", 13 | }, 14 | } 15 | 16 | INSTALLED_APPS = [ 17 | "django_markup", 18 | ] 19 | 20 | TEMPLATES = [ 21 | { 22 | "BACKEND": "django.template.backends.django.DjangoTemplates", 23 | "DIRS": [ 24 | Path(__file__).parent / "templates", 25 | ], 26 | "APP_DIRS": True, 27 | "OPTIONS": { 28 | "context_processors": [ 29 | "django.template.context_processors.debug", 30 | "django.template.context_processors.request", 31 | "django.template.context_processors.i18n", 32 | ], 33 | }, 34 | }, 35 | ] 36 | -------------------------------------------------------------------------------- /django_markup/tests/templates/test_templatetag.html: -------------------------------------------------------------------------------- 1 | {% load markup_tags %} 2 | {{ text|apply_markup:filter }} 3 | -------------------------------------------------------------------------------- /django_markup/tests/templates/test_templatetag_filterwrapper.html: -------------------------------------------------------------------------------- 1 | {% load markup_tags %} 2 | {% filter apply_markup:filter %}{{ text }}{% endfilter %} 3 | -------------------------------------------------------------------------------- /django_markup/tests/test_custom_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import pytest 6 | from django.test import TestCase 7 | 8 | from django_markup.filter import MarkupFilter 9 | from django_markup.markup import UnregisteredFilterError, formatter 10 | 11 | 12 | class UppercaseMarkupFilter(MarkupFilter): 13 | """ 14 | Custom filter that makes all the text uppercase. 15 | """ 16 | 17 | title = "UppercaseFilter" 18 | 19 | def render( 20 | self, 21 | text: str, 22 | **kwargs: Any, # Unused argument 23 | ) -> str: 24 | return text.upper() 25 | 26 | 27 | class LowercaseMarkupFilter(MarkupFilter): 28 | """ 29 | Custom filter that makes all the text lowercase. 30 | """ 31 | 32 | title = "LowercaseFilter" 33 | 34 | def render( 35 | self, 36 | text: str, 37 | **kwargs: Any, # Unused argument 38 | ) -> str: 39 | return text.lower() 40 | 41 | 42 | class CustomMarkupFilterTestCase(TestCase): 43 | """ 44 | Test the registration/unregistration of a custom filter. 45 | """ 46 | 47 | def test_register_filter(self) -> None: 48 | """ 49 | Register the filter, and its wildly available. 50 | """ 51 | formatter.register("uppercase", UppercaseMarkupFilter) 52 | 53 | # It's ready to be called 54 | result = formatter("This is some text", filter_name="uppercase") 55 | assert result == "THIS IS SOME TEXT" 56 | 57 | def test_update_filter(self) -> None: 58 | """ 59 | You can update an existing filter, but keep the name. 60 | """ 61 | formatter.update("uppercase", LowercaseMarkupFilter) 62 | 63 | # Despite its key name is still 'uppercase' we actually call the 64 | # LowercaseFilter. 65 | result = formatter("This Is Some Text", filter_name="uppercase") 66 | assert result == "this is some text" 67 | 68 | def test_unregister_filter(self) -> None: 69 | # Unregistering a filter that does not exist is simply ignored 70 | formatter.unregister("does-not-exist") 71 | 72 | # Unregistering an registered filter, and it no longer works and will 73 | # raise a ValueError. 74 | formatter.register("uppercase", UppercaseMarkupFilter) 75 | formatter.unregister("uppercase") 76 | 77 | pytest.raises( 78 | UnregisteredFilterError, 79 | formatter, 80 | "This is some text", 81 | filter_name="uppercase", 82 | ) 83 | 84 | def test_fallback_filter(self) -> None: 85 | """ 86 | You can call the formatter without a `filter_name` as long as a 87 | `MARKUP_FILTER_FALLBACK` setting is set. 88 | """ 89 | pytest.raises( 90 | UnregisteredFilterError, 91 | formatter, 92 | "This is some text", 93 | filter_name=None, 94 | ) 95 | 96 | formatter.register("uppercase", UppercaseMarkupFilter) 97 | 98 | with self.settings(MARKUP_FILTER_FALLBACK="uppercase"): 99 | result = formatter("This is some text", filter_name=None) 100 | assert result == "THIS IS SOME TEXT" 101 | -------------------------------------------------------------------------------- /django_markup/tests/test_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | from django.test import TestCase 7 | 8 | from django_markup.markup import UnregisteredFilterError, formatter 9 | 10 | from . import markup_strings as s 11 | 12 | FILES_DIR = Path(__file__).parent / "files" 13 | 14 | 15 | class FormatterTestCase(TestCase): 16 | """ 17 | Test the Formatter conversion done in Python of all shipped filters. 18 | """ 19 | 20 | def read(self, filename: str) -> str: 21 | with (FILES_DIR / filename).open("r") as f: 22 | return f.read() 23 | 24 | def test_unregistered_filter_fails_loud(self) -> None: 25 | """ 26 | Trying to call a unregistered filter will raise a ValueError. 27 | """ 28 | pytest.raises( 29 | UnregisteredFilterError, 30 | formatter, 31 | "some text", 32 | filter_name="does-not-exist", 33 | ) 34 | 35 | def test_none_filter(self) -> None: 36 | text, expected = s.NONE 37 | result = formatter(text, filter_name="none") 38 | assert result == expected 39 | 40 | def test_linebreaks_filter(self) -> None: 41 | text, expected = s.LINEBREAKS 42 | result = formatter(text, filter_name="linebreaks") 43 | assert result == expected 44 | 45 | def test_markdown_filter(self) -> None: 46 | text, expected = s.MARKDOWN 47 | result = formatter(text, filter_name="markdown") 48 | assert result == expected 49 | 50 | def test_markdown_filter_pre(self) -> None: 51 | text, expected = s.MARKDOWN_PRE 52 | result = formatter(text, filter_name="markdown") 53 | assert result == expected 54 | 55 | def test_markdown_safemode_enabled_by_default(self) -> None: 56 | """Safe mode is enabled by default.""" 57 | text, expected = s.MARKDOWN_JS_LINK 58 | result = formatter(text, filter_name="markdown") 59 | assert result == expected 60 | 61 | def test_textile_filter(self) -> None: 62 | text, expected = s.TEXTILE 63 | result = formatter(text, filter_name="textile") 64 | assert result == expected 65 | 66 | def test_rst_filter(self) -> None: 67 | text, expected = s.RST 68 | result = formatter(text, filter_name="restructuredtext") 69 | assert result == expected 70 | 71 | def test_rst_header_levels(self) -> None: 72 | """ 73 | Make sure the rST filter fetches the entire document rather just the 74 | document fragment. 75 | 76 | :see: https://github.com/bartTC/django-markup/issues/14 77 | """ 78 | text = self.read("rst_header.txt") 79 | expected = self.read("rst_header_expected.txt") 80 | result = formatter(text, filter_name="restructuredtext") 81 | assert result == expected 82 | 83 | def test_rst_with_pygments(self) -> None: 84 | """ 85 | Having Pygments installed will automatically provide a ``.. code-block`` 86 | directive in reStructredText to highlight code snippets. 87 | """ 88 | text = self.read("rst_with_pygments.txt") 89 | expected = self.read("rst_with_pygments_expected.txt") 90 | result = formatter(text, filter_name="restructuredtext") 91 | 92 | assert result == expected 93 | 94 | def test_rst_raw_default(self) -> None: 95 | """Raw file inclusion is disabled by default.""" 96 | text = self.read("rst_raw.txt") 97 | result = formatter(text, filter_name="restructuredtext") 98 | assert "Other text" in result 99 | assert "