├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_loading.py │ ├── test_smilies.py │ ├── test_fields.py │ ├── test_placeholders.py │ ├── test_parser.py │ └── test_tags.py ├── _testsite │ ├── __init__.py │ ├── dummyapp01 │ │ ├── __init__.py │ │ ├── apps.py │ │ └── dummymodule01.py │ ├── dummyapp02 │ │ ├── __init__.py │ │ ├── apps.py │ │ └── dummymodule02.py │ └── urls.py ├── integration │ ├── __init__.py │ ├── test_jinja2tags.py │ └── test_templatetags.py ├── _testdata │ └── media │ │ └── icon_e_wink.gif ├── models.py └── settings.py ├── precise_bbcode ├── __init__.py ├── conf │ ├── __init__.py │ └── settings.py ├── core │ ├── __init__.py │ ├── utils.py │ ├── compat.py │ └── loading.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── bbcode │ ├── defaults │ │ ├── __init__.py │ │ ├── placeholder.py │ │ └── tag.py │ ├── exceptions.py │ ├── regexes.py │ ├── placeholder.py │ ├── __init__.py │ └── tag.py ├── templatetags │ ├── __init__.py │ └── bbcode_tags.py ├── locale │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── apps.py ├── shortcuts.py ├── test.py ├── jinja2tags.py ├── admin.py ├── placeholder_pool.py ├── tag_pool.py ├── fields.py └── models.py ├── .codecov.yml ├── example_project ├── example_project │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── dev.py │ │ └── base.py │ ├── static │ │ └── less │ │ │ ├── theme.css │ │ │ └── theme.less │ ├── wsgi.py │ ├── urls.py │ └── jinja2 │ │ └── base.html ├── test_messages │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── jinja2 │ │ └── test_messages │ │ │ ├── bbcode_message.html │ │ │ └── create_bbcode_message.html │ ├── admin.py │ ├── forms.py │ ├── models.py │ ├── bbcode_placeholders.py │ ├── views.py │ └── bbcode_tags.py └── manage.py ├── .flake8 ├── pytest.ini ├── .isort.cfg ├── .gitignore ├── .coveragerc ├── docs ├── extending_precise_bbcode │ ├── index.rst │ ├── custom_smilies.rst │ ├── custom_placeholders.rst │ └── custom_tags.rst ├── basic_reference │ ├── index.rst │ ├── storing_bbcodes.rst │ ├── rendering_bbcodes.rst │ └── builtin_bbcodes.rst ├── getting_started.rst ├── settings.rst ├── index.rst ├── Makefile └── conf.py ├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── requirements-doc.freeze ├── LICENSE ├── pyproject.toml ├── Makefile └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /precise_bbcode/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_testsite/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | -------------------------------------------------------------------------------- /precise_bbcode/conf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /precise_bbcode/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /precise_bbcode/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp01/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp02/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/test_messages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/defaults/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /precise_bbcode/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example_project/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example_project/static/less/theme.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/test_messages/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example_project/static/less/theme.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = migrations,build,docs,.tox,.venv,dist 3 | ignore = E731, W504, W605 4 | max-line-length = 100 5 | -------------------------------------------------------------------------------- /tests/_testdata/media/icon_e_wink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellmetha/django-precise-bbcode/HEAD/tests/_testdata/media/icon_e_wink.gif -------------------------------------------------------------------------------- /precise_bbcode/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellmetha/django-precise-bbcode/HEAD/precise_bbcode/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | django_find_project = false 3 | norecursedirs = build src .tox node_modules 4 | addopts = --ds=tests.settings --reuse-db 5 | python_paths = ./ 6 | -------------------------------------------------------------------------------- /precise_bbcode/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PreciseBbCodeAppConfig(AppConfig): 5 | name = 'precise_bbcode' 6 | verbose_name = 'Precise BBCode' 7 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp01/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DummyAppConfig(AppConfig): 5 | name = 'tests._testsite.dummyapp01' 6 | verbose_name = 'Dummy App' 7 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp02/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DummyAppConfig(AppConfig): 5 | name = 'tests._testsite.dummyapp02' 6 | verbose_name = 'Dummy App' 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | default_section = THIRDPARTY 3 | force_single_line=true 4 | known_first_party = precise_bbcode 5 | line_length=100 6 | lines_after_imports = 2 7 | skip=migrations,_testsite 8 | -------------------------------------------------------------------------------- /example_project/test_messages/jinja2/test_messages/bbcode_message.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | {{ object.bbcode_content.rendered }} 5 | {% endblock content %} 6 | -------------------------------------------------------------------------------- /example_project/example_project/settings/dev.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | 4 | DEBUG = True 5 | 6 | ALLOWED_HOSTS = [ 7 | '127.0.0.1', 8 | 'localhost', 9 | ] 10 | 11 | INTERNAL_IPS = ( 12 | '127.0.0.1', 13 | ) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.db 4 | __pycache__ 5 | .coverage 6 | *.egg-info/ 7 | *.egg/ 8 | dist 9 | build 10 | docs/_build/ 11 | public/ 12 | .tox 13 | tests/_testdata/media/ 14 | .cache/ 15 | .pytest_cache/ 16 | .vscode/ 17 | .python-version 18 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidBBCodeTag(Exception): 2 | """The bbcode tag is not valid and cannot be used.""" 3 | pass 4 | 5 | 6 | class InvalidBBCodePlaholder(Exception): 7 | """The placeholder is not valid and cannot be used.""" 8 | pass 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __unicode__ 5 | def __str__ 6 | def __repr__ 7 | def south_field_triple 8 | raise NotImplementedError 9 | omit = 10 | *compat* 11 | *django_load* 12 | *migrations* 13 | *test_* 14 | -------------------------------------------------------------------------------- /precise_bbcode/shortcuts.py: -------------------------------------------------------------------------------- 1 | from precise_bbcode.bbcode import get_parser 2 | 3 | 4 | def render_bbcodes(text): 5 | """ 6 | Given an input text, calls the BBCode parser to get the corresponding HTML output. 7 | """ 8 | parser = get_parser() 9 | return parser.render(text) 10 | -------------------------------------------------------------------------------- /tests/_testsite/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 3 | from django.urls import re_path 4 | 5 | 6 | urlpatterns = [ 7 | re_path(r'^admin/', admin.site.urls), 8 | ] 9 | urlpatterns += staticfiles_urlpatterns() 10 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings.dev") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /docs/extending_precise_bbcode/index.rst: -------------------------------------------------------------------------------- 1 | ######################## 2 | Extending Precise BBCode 3 | ######################## 4 | 5 | *Django-precise-bbcode* allows the creation of custom BBCode tags, placeholders and smilies. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | custom_tags 11 | custom_placeholders 12 | custom_smilies 13 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from precise_bbcode.fields import BBCodeTextField 4 | 5 | 6 | class DummyMessage(models.Model): 7 | """ 8 | This model will be use for testing purposes. 9 | """ 10 | content = BBCodeTextField(null=True, blank=True) 11 | 12 | class Meta: 13 | app_label = 'tests' 14 | -------------------------------------------------------------------------------- /example_project/test_messages/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import TestMessage 4 | 5 | 6 | class TestMessageAdmin(admin.ModelAdmin): 7 | list_display = ('id', 'bbcode_content') 8 | list_display_links = ('id', 'bbcode_content') 9 | fields = ('bbcode_content', ) 10 | 11 | 12 | admin.site.register(TestMessage, TestMessageAdmin) 13 | -------------------------------------------------------------------------------- /precise_bbcode/core/utils.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | 3 | 4 | def replace(data, replacements): 5 | """ 6 | Performs several string substitutions on the initial ``data`` string using 7 | a list of 2-tuples (old, new) defining substitutions and returns the resulting 8 | string. 9 | """ 10 | return reduce(lambda a, kv: a.replace(*kv), replacements, data) 11 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp01/dummymodule01.py: -------------------------------------------------------------------------------- 1 | from precise_bbcode.bbcode.tag import BBCodeTag 2 | from precise_bbcode.tag_pool import tag_pool 3 | 4 | 5 | class LoadDummyTag(BBCodeTag): 6 | name = 'loaddummy01' 7 | definition_string = '[loaddummy01]{TEXT}[/loaddummy01]' 8 | format_string = '{TEXT}' 9 | 10 | 11 | tag_pool.register_tag(LoadDummyTag) 12 | -------------------------------------------------------------------------------- /tests/_testsite/dummyapp02/dummymodule02.py: -------------------------------------------------------------------------------- 1 | from precise_bbcode.bbcode.tag import BBCodeTag 2 | from precise_bbcode.tag_pool import tag_pool 3 | 4 | 5 | class LoadDummyTag(BBCodeTag): 6 | name = 'loaddummy02' 7 | definition_string = '[loaddummy02]{TEXT}[/loaddummy02]' 8 | format_string = '{TEXT}' 9 | 10 | 11 | tag_pool.register_tag(LoadDummyTag) 12 | -------------------------------------------------------------------------------- /docs/basic_reference/index.rst: -------------------------------------------------------------------------------- 1 | ########################## 2 | Basic reference & guidance 3 | ########################## 4 | 5 | Once you have *django-precise-bbcode* working, you'll want to explore its features and capabilities in more 6 | depth. Here you will learn to use the BBCodes provided by the module. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | builtin_bbcodes 12 | rendering_bbcodes 13 | storing_bbcodes 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{ini,py,rst}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{css,html,js,json,jsx,less,scss,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /example_project/test_messages/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import TestMessage 4 | 5 | 6 | class TestMessageForm(forms.ModelForm): 7 | class Meta: 8 | model = TestMessage 9 | fields = ['bbcode_content', ] 10 | 11 | def __init__(self, *args, **kwargs): 12 | super(TestMessageForm, self).__init__(*args, **kwargs) 13 | self.fields['bbcode_content'].widget.attrs['class'] = 'form-control textarea' 14 | -------------------------------------------------------------------------------- /tests/unit/test_loading.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from precise_bbcode.core.loading import load 4 | from precise_bbcode.tag_pool import tag_pool 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestLoadFunction(object): 9 | def test_can_load_modules_from_classic_apps(self): 10 | # Setup 11 | load('dummymodule01') 12 | assert 'loaddummy01' in tag_pool.tags 13 | 14 | def test_can_load_modules_from_appconfig_classes(self): 15 | # Setup 16 | load('dummymodule02') 17 | assert 'loaddummy02' in tag_pool.tags 18 | -------------------------------------------------------------------------------- /example_project/test_messages/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from precise_bbcode.fields import BBCodeTextField 4 | 5 | 6 | class TestMessage(models.Model): 7 | bbcode_content = BBCodeTextField(verbose_name=_('BBCode content')) 8 | 9 | class Meta: 10 | verbose_name = _('Test message') 11 | verbose_name_plural = _('Test messages') 12 | app_label = 'test_messages' 13 | 14 | def __str__(self): 15 | return '{}'.format(self.id if self.id else _('new message')) 16 | -------------------------------------------------------------------------------- /precise_bbcode/test.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_str 2 | 3 | from .bbcode.tag import BBCodeTag 4 | 5 | 6 | def gen_bbcode_tag_klass(klass_attrs, options_attrs={}): 7 | # Construc the inner Options class 8 | options_klass = type(force_str('Options'), (), options_attrs) 9 | # Construct the outer BBCodeTag class 10 | tag_klass_attrs = klass_attrs 11 | tag_klass_attrs['Options'] = options_klass 12 | tag_klass = type( 13 | force_str('{}Tag'.format(tag_klass_attrs['name'])), (BBCodeTag, ), tag_klass_attrs) 14 | return tag_klass 15 | -------------------------------------------------------------------------------- /precise_bbcode/core/compat.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def with_metaclass(meta, *bases): 5 | """Create a base class with a metaclass.""" 6 | class metaclass(meta): # noqa 7 | __call__ = type.__call__ 8 | __init__ = type.__init__ 9 | 10 | def __new__(cls, name, this_bases, d): 11 | if this_bases is None: 12 | return type.__new__(cls, name, (), d) 13 | return meta(name, bases, d) 14 | return metaclass("NewBase", None, {}) 15 | 16 | 17 | pattern_type = re._pattern_type if hasattr(re, '_pattern_type') else re.Pattern 18 | -------------------------------------------------------------------------------- /precise_bbcode/jinja2tags.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Markup 2 | from jinja2.ext import Extension 3 | 4 | from .shortcuts import render_bbcodes 5 | 6 | 7 | def do_bbcode(text): 8 | return Markup(render_bbcodes(text)) 9 | 10 | 11 | class PreciseBBCodeExtension(Extension): 12 | def __init__(self, environment): 13 | super(PreciseBBCodeExtension, self).__init__(environment) 14 | 15 | self.environment.globals.update({ 16 | 'bbcode': do_bbcode, 17 | }) 18 | self.environment.filters.update({ 19 | 'bbcode': do_bbcode, 20 | }) 21 | 22 | 23 | bbcode = PreciseBBCodeExtension 24 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Requirements 5 | ------------ 6 | 7 | * `Python`_ 3.6+ 8 | * `Django`_ 3.2_ 9 | * `Pillow`_ 2.2. or higher 10 | 11 | .. _Python: https://www.python.org 12 | .. _Django: https://www.djangoproject.com 13 | .. _Pillow: http://python-pillow.github.io/ 14 | 15 | Installing 16 | ---------- 17 | 18 | Install *django-precise-bbcode* using:: 19 | 20 | pip install django-precise-bbcode 21 | 22 | Add ``precise_bbcode`` to ``INSTALLED_APPS`` in your project's settings module:: 23 | 24 | INSTALLED_APPS = ( 25 | # other apps 26 | 'precise_bbcode', 27 | ) 28 | 29 | Then install the models:: 30 | 31 | python manage.py migrate 32 | -------------------------------------------------------------------------------- /example_project/test_messages/bbcode_placeholders.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from precise_bbcode.bbcode.placeholder import BBCodePlaceholder 4 | from precise_bbcode.placeholder_pool import placeholder_pool 5 | 6 | 7 | class PhoneNumberBBCodePlaceholder(BBCodePlaceholder): 8 | name = 'phonenumber' 9 | pattern = re.compile( 10 | r'(\d{3}[-\.\s]??\d{3}[-\.\s]??\d{4}|\(\d{3}\)\s*\d{3}[-\.\s]??\d{4}|\d{3}[-\.\s]??\d{4})' 11 | ) 12 | 13 | 14 | class StartsWithBBCodePlaceholder(BBCodePlaceholder): 15 | name = 'startswith' 16 | 17 | def validate(self, content, extra_context): 18 | return content.startswith(extra_context) 19 | 20 | 21 | placeholder_pool.register_placeholder(PhoneNumberBBCodePlaceholder) 22 | placeholder_pool.register_placeholder(StartsWithBBCodePlaceholder) 23 | -------------------------------------------------------------------------------- /example_project/test_messages/views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse_lazy 2 | from django.views.generic.detail import DetailView 3 | from django.views.generic.edit import CreateView 4 | 5 | from .forms import TestMessageForm 6 | from .models import TestMessage 7 | 8 | 9 | class TestMessageCreate(CreateView): 10 | model = TestMessage 11 | form_class = TestMessageForm 12 | template_name = 'test_messages/create_bbcode_message.html' 13 | 14 | def get_success_url(self): 15 | return "{0}?success=true".format( 16 | reverse_lazy('bbcode-message-detail', kwargs={'message_pk': self.object.id}) 17 | ) 18 | 19 | 20 | class TestMessageDetailView(DetailView): 21 | model = TestMessage 22 | template_name = 'test_messages/bbcode_message.html' 23 | pk_url_kwarg = 'message_pk' 24 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/regexes.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | # Common regexes 5 | url_re = re.compile(r'(?im)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:\'".,<>?]))') # noqa 6 | 7 | 8 | # BBCode placeholder regexes 9 | placeholder_re = re.compile(r'{([a-zA-Z]+\d*=?[^\s\[\]\{\}=]*)}') 10 | placeholder_content_re = re.compile(r'^(?P[a-zA-Z]+)(\d*)(=[^\s\[\]\{\}=]*)?$') 11 | 12 | 13 | # BBCode regexes 14 | bbcodde_standard_re = r'^\[(?P[^\s=\[\]]*)(=\{[a-zA-Z]+\d*=?[^\s\[\]\{\}=]*\})?\]\{[a-zA-Z]+\d*=?[^\s\[\]\{\}=]*\}(\[/(?P[^\s=\[\]]*)\])?$' # noqa 15 | bbcodde_standalone_re = r'^\[(?P[^\s=\[\]]*)(=\{[a-zA-Z]+\d*=?[^\s\[\]\{\}=]*\})?\]\{?[a-zA-Z]*\d*=?[^\s\[\]\{\}=]*\}?$' # noqa 16 | bbcode_content_re = re.compile(r'^\[[A-Za-z0-9]*\](?P.*)\[/[A-Za-z0-9]*\]') 17 | -------------------------------------------------------------------------------- /docs/extending_precise_bbcode/custom_smilies.rst: -------------------------------------------------------------------------------- 1 | ############## 2 | Custom smilies 3 | ############## 4 | 5 | *Django-precise-bbcode* does not come with some built-in smilies but supports adding custom smilies and emoticons through the Django admin. 6 | 7 | To add a smiley, just go to the admin page and you will see a new 'Smileys' section. In this section you can create and edit custom smilies. The smileys defined through the Django admin are then used by the built-in BBCode parser to transform any *smiley code* to the corresponding HTML. 8 | 9 | Adding a smiley consists in filling at least the following fields: 10 | 11 | * ``code``: The smiley code - it's the textual shortcut that the end users will use to include emoticons inside their bbcode contents. This text can be composed of any character without whitespace characters (eg. ``;)`` or ``-_-``) 12 | * ``image``: The smiley image 13 | 14 | The size at which the emoticon image is rendered can also be specified by using the ``image_width`` and ``image_height`` fields. -------------------------------------------------------------------------------- /example_project/test_messages/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import precise_bbcode.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='TestMessage', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('_bbcode_content_rendered', models.TextField(null=True, editable=False, blank=True)), 19 | ('bbcode_content', precise_bbcode.fields.BBCodeTextField(no_rendered_field=True, verbose_name='BBCode content')), 20 | ], 21 | options={ 22 | 'verbose_name': 'Test message', 23 | 'verbose_name_plural': 'Test messages', 24 | }, 25 | bases=(models.Model,), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /precise_bbcode/core/loading.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | 4 | from django.apps import apps 5 | 6 | 7 | def get_module(app, modname): 8 | """ 9 | Internal function to load a module from a single app. 10 | """ 11 | # Check if the module exists. 12 | if importlib.util.find_spec('{}.{}'.format(app, modname)) is None: 13 | return 14 | 15 | # Import the app's module file 16 | importlib.import_module('{}.{}'.format(app, modname)) 17 | 18 | 19 | def load(modname): 20 | """ 21 | Loads all modules with name 'modname' from all installed apps. 22 | """ 23 | app_names = [app.name for app in apps.app_configs.values()] 24 | for app in app_names: 25 | get_module(app, modname) 26 | 27 | 28 | def get_subclasses(mod, cls): 29 | """ 30 | Yield the classes in module 'mod' that inherit from 'cls'. 31 | """ 32 | for name, obj in inspect.getmembers(mod): 33 | if obj != cls and inspect.isclass(obj) and issubclass(obj, cls): 34 | yield obj 35 | -------------------------------------------------------------------------------- /precise_bbcode/conf/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | # The HTML tag to make a line break 5 | BBCODE_NEWLINE = getattr(settings, 'BBCODE_NEWLINE', '
') 6 | 7 | # The HTML special characters to be escaped during the rendering process 8 | escape_html = ( 9 | ('&', '&'), 10 | ('<', '<'), 11 | ('>', '>'), 12 | ('"', '"'), 13 | ('\'', '''), 14 | ) 15 | BBCODE_ESCAPE_HTML = getattr(settings, 'BBCODE_ESCAPE_HTML', escape_html) 16 | 17 | 18 | # Should the built-in tags be disabled? 19 | BBCODE_DISABLE_BUILTIN_TAGS = getattr(settings, 'BBCODE_DISABLE_BUILTIN_TAGS', False) 20 | 21 | 22 | # Should custom tags be allowed? 23 | BBCODE_ALLOW_CUSTOM_TAGS = getattr(settings, 'BBCODE_ALLOW_CUSTOM_TAGS', True) 24 | 25 | 26 | # Other options 27 | BBCODE_NORMALIZE_NEWLINES = getattr(settings, 'BBCODE_NORMALIZE_NEWLINES', True) 28 | 29 | 30 | # Smileys options 31 | BBCODE_ALLOW_SMILIES = getattr(settings, 'BBCODE_ALLOW_SMILIES', True) 32 | SMILIES_UPLOAD_TO = getattr(settings, 'BBCODE_SMILIES_UPLOAD_TO', 'precise_bbcode/smilies') 33 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for yrise project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | 17 | # This application object is used by any WSGI server configured to use this 18 | # file. This includes Django's development server, if the WSGI_APPLICATION 19 | # setting points here. 20 | from django.core.wsgi import get_wsgi_application 21 | application = get_wsgi_application() 22 | 23 | # Apply WSGI middleware here. 24 | # from helloworld.wsgi import HelloWorldApplication 25 | # application = HelloWorldApplication(application) 26 | -------------------------------------------------------------------------------- /example_project/example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 4 | from django.urls import re_path 5 | from django.views.static import serve 6 | from test_messages.views import TestMessageCreate 7 | from test_messages.views import TestMessageDetailView 8 | 9 | 10 | # Admin autodiscover 11 | admin.autodiscover() 12 | 13 | # Patterns 14 | urlpatterns = [ 15 | # Admin 16 | re_path(r'^' + settings.ADMIN_URL, admin.site.urls), 17 | 18 | # Apps 19 | re_path(r'^$', TestMessageCreate.as_view()), 20 | re_path( 21 | r'^testmessage/(?P\d+)/$', 22 | TestMessageDetailView.as_view(), 23 | name="bbcode-message-detail" 24 | ), 25 | ] 26 | 27 | # # In DEBUG mode, serve media files through Django. 28 | if settings.DEBUG: 29 | urlpatterns += staticfiles_urlpatterns() 30 | # Remove leading and trailing slashes so the regex matches. 31 | media_url = settings.MEDIA_URL.lstrip('/').rstrip('/') 32 | urlpatterns += [ 33 | re_path(r'^%s/(?P.*)$' % media_url, serve, {'document_root': settings.MEDIA_ROOT}), 34 | ] 35 | -------------------------------------------------------------------------------- /example_project/test_messages/jinja2/test_messages/create_bbcode_message.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 | 7 |
8 |
9 |
10 | {% if form.bbcode_content.label %}{% endif %} 11 | {{ form.bbcode_content }} 12 |

{{ form.bbcode_content.help_text }}

13 | {% for error in form.bbcode_content.errors %}

{{ error }}

{% endfor %} 14 |
15 |
16 | 17 |
18 |
19 | {{ "[b]Write some bbcodes![/b] The [i]current[/i] text was generated by using the 'bbcode' filter..."|bbcode }} 20 |
21 | {{ bbcode("This [b]one[/b] was generated using the [i][color=green]'bbcode'[/color][/i] template tag.") }} 22 | {% endblock content %} 23 | -------------------------------------------------------------------------------- /docs/basic_reference/storing_bbcodes.rst: -------------------------------------------------------------------------------- 1 | ############### 2 | Storing BBCodes 3 | ############### 4 | 5 | The Django built-in ``models.TextField`` is all you need to simply add BBCode contents to your models. However, a common need is to store both the BBCode content and the corresponding HTML markup in the database. To address this *django-precise-bbcode* provides a ``BBCodeTextField``:: 6 | 7 | from django.db import models 8 | from precise_bbcode.fields import BBCodeTextField 9 | 10 | class Post(models.Model): 11 | content = BBCodeTextField() 12 | 13 | A ``BBCodeTextField`` field contributes two columns to the model instead of a standard single column : one is used to save the BBCode content ; the other one keeps the corresponding HTML markup. The HTML content of such a field can then be displayed in any template by using its ``rendered`` attribute:: 14 | 15 | {{ post.content.rendered }} 16 | 17 | .. note:: 18 | 19 | Please note that you should to use the ``safe`` template filter when displaying your BBCodes if you choose to store BBCode contents in the built-in ``models.TextField``. 20 | This is because ``models.TextField`` contents are escaped by default. 21 | 22 | :: 23 | 24 | {% load bbcode_tags %} 25 | {{ object.bbcode_content|safe|bbcode }} 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [ 12 | 3.7, 13 | 3.8, 14 | 3.9, 15 | '3.10', 16 | '3.11', 17 | ] 18 | django-version: [ 19 | "django>=3.2,<4.0", 20 | "django>=4.0,<4.1", 21 | "django>=4.1,<4.2", 22 | "django>=4.2,<5.0", 23 | ] 24 | exclude: 25 | - python-version: 3.7 26 | django-version: "django>=4.0,<4.1" 27 | - python-version: 3.7 28 | django-version: "django>=4.1,<4.2" 29 | - python-version: 3.7 30 | django-version: "django>=4.2,<5.0" 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v1 36 | with: 37 | python-version: '${{ matrix.python-version }}' 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -U pip poetry 42 | poetry install 43 | poetry run pip install -U "${{ matrix.django-version }}" 44 | - name: Run QA checks 45 | run: make qa 46 | - name: Run tests suite 47 | run: poetry run pytest 48 | -------------------------------------------------------------------------------- /requirements-doc.freeze: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | appnope==0.1.0 3 | asgiref==3.2.3 4 | atomicwrites==1.3.0 5 | attrs==19.1.0 6 | Babel==2.7.0 7 | backcall==0.1.0 8 | certifi==2019.6.16 9 | chardet==3.0.4 10 | coverage==4.5.4 11 | decorator==4.4.0 12 | Django==3.0.2 13 | django-debug-toolbar==2.0 14 | docutils==0.15.2 15 | entrypoints==0.3 16 | factory-boy==2.12.0 17 | Faker==2.0.0 18 | flake8==3.7.8 19 | idna==2.8 20 | imagesize==1.1.0 21 | importlib-metadata==0.19 22 | ipython==7.7.0 23 | ipython-genutils==0.2.0 24 | isort==4.3.21 25 | jedi==0.14.1 26 | Jinja2==2.10.1 27 | MarkupSafe==1.1.1 28 | mccabe==0.6.1 29 | mock==3.0.5 30 | more-itertools==7.2.0 31 | packaging==19.1 32 | parso==0.5.1 33 | pexpect==4.7.0 34 | pickleshare==0.7.5 35 | Pillow==6.1.0 36 | pluggy==0.12.0 37 | prompt-toolkit==2.0.9 38 | ptyprocess==0.6.0 39 | py==1.8.0 40 | pycodestyle==2.5.0 41 | pyflakes==2.1.1 42 | Pygments==2.4.2 43 | pyparsing==2.4.2 44 | pytest==5.0.1 45 | pytest-cov==2.7.1 46 | pytest-django==3.5.1 47 | pytest-pythonpath==0.7.3 48 | pytest-spec==1.1.0 49 | python-dateutil==2.8.0 50 | pytz==2019.2 51 | requests==2.22.0 52 | six==1.12.0 53 | snowballstemmer==1.9.0 54 | Sphinx==2.1.2 55 | sphinx-rtd-theme==0.4.3 56 | sphinxcontrib-applehelp==1.0.1 57 | sphinxcontrib-devhelp==1.0.1 58 | sphinxcontrib-htmlhelp==1.0.2 59 | sphinxcontrib-jsmath==1.0.1 60 | sphinxcontrib-qthelp==1.0.2 61 | sphinxcontrib-serializinghtml==1.1.3 62 | sqlparse==0.3.0 63 | text-unidecode==1.2 64 | traitlets==4.3.2 65 | urllib3==1.25.3 66 | wcwidth==0.1.7 67 | zipp==0.5.2 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2025, Morgan Aubert and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Morgan Aubert nor the names of the contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-precise-bbcode" 3 | version = "1.2.17.dev" 4 | description = "A django BBCode integration.." 5 | authors = ["Morgan Aubert "] 6 | license = "BSD-3-Clause" 7 | readme = "README.rst" 8 | homepage = "https://github.com/ellmetha/django-precise-bbcode" 9 | repository = "https://github.com/ellmetha/django-precise-bbcode" 10 | keywords = ["django", "bbdode", "html"] 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Natural Language :: English", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.6", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | ] 27 | packages = [ 28 | { include = "precise_bbcode" }, 29 | ] 30 | include = ["LICENSE", "*.rst"] 31 | exclude = ["example_project", "tests"] 32 | 33 | [tool.poetry.dependencies] 34 | python = "^3.6" 35 | 36 | django = "^3.2 || >=4.0" 37 | pillow = ">=2.2.1" 38 | 39 | [tool.poetry.dev-dependencies] 40 | django-debug-toolbar = "*" 41 | flake8 = "*" 42 | ipython = "*" 43 | isort = "*" 44 | pytest = "^6.2.5" 45 | pytest-cov = "*" 46 | pytest-spec = "*" 47 | pytest-django = "*" 48 | pytest-pythonpath = "^0.7.3" 49 | jinja2 = "^2.10.3" 50 | sphinx = "*" 51 | sphinx-rtd-theme = "*" 52 | 53 | [build-system] 54 | requires = ["poetry>=0.12"] 55 | build-backend = "poetry.masonry.api" 56 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Settings 3 | ######## 4 | 5 | This is a comprehensive list of all the settings *Django-precise-bbcode* provides. All settings are optional. 6 | 7 | Parser settings 8 | *************** 9 | 10 | ``BBCODE_NEWLINE`` 11 | ------------------ 12 | 13 | Default: ``'
'`` 14 | 15 | The HTML replacement code for a default newline. 16 | 17 | ``BBCODE_ESCAPE_HTML`` 18 | ---------------------- 19 | 20 | Default:: 21 | 22 | ( 23 | ('&', '&'), 24 | ('<', '<'), 25 | ('>', '>'), 26 | ('"', '"'), 27 | ('\'', '''), 28 | ) 29 | 30 | The list of all characters that must be escaped before rendering. 31 | 32 | ``BBCODE_DISABLE_BUILTIN_TAGS`` 33 | ------------------------------- 34 | 35 | Default: ``False`` 36 | 37 | The flag indicating whether the built-in BBCode tags should be disabled or not. 38 | 39 | ``BBCODE_ALLOW_CUSTOM_TAGS`` 40 | ---------------------------- 41 | 42 | Default: ``True`` 43 | 44 | The flag indicating whether the custom BBCode tags (those defined by the end users through the Django admin) are allowed. 45 | 46 | ``BBCODE_NORMALIZE_NEWLINES`` 47 | ----------------------------- 48 | 49 | Default: ``True`` 50 | 51 | The flag indicating whether the newlines are normalized (if this is the case all newlines are replaced with ``\r\n``). 52 | 53 | Smilies settings 54 | **************** 55 | 56 | ``BBCODE_ALLOW_SMILIES`` 57 | ------------------------ 58 | 59 | Default: ``True`` 60 | 61 | The flag indicating whether the smilies (defined by the end users through the Django admin) are allowed. 62 | 63 | ``BBCODE_SMILIES_UPLOAD_TO`` 64 | ---------------------------- 65 | 66 | Default: ``'precise_bbcode/smilies'`` 67 | 68 | The media subdirectory where the smilies should be uploaded. 69 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ################################################# 2 | Welcome to django-precise-bbcode's documentation! 3 | ################################################# 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-precise-bbcode.svg?style=flat-square 6 | :target: https://pypi.python.org/pypi/django-precise-bbcode/ 7 | :alt: Latest Version 8 | 9 | .. image:: https://img.shields.io/travis/ellmetha/django-precise-bbcode.svg?style=flat-square 10 | :target: http://travis-ci.org/ellmetha/django-precise-bbcode 11 | :alt: Build status 12 | 13 | | 14 | 15 | **Django-precise-bbcode** is a Django application providing a way to create textual contents based on BBCodes. 16 | 17 | BBCode is a special implementation of HTML. BBCode itself is similar in style to HTML, tags are enclosed in square brackets [ and ] rather than < and > and it offers greater control over what and how something is displayed. 18 | 19 | This application includes a BBCode compiler aimed to render any BBCode content to HTML and allows the use of BBCodes tags in models, forms and admin forms. The BBCode parser comes with built-in tags (the default ones ; ``b``, ``u``, ``i``, etc) and allows the definition of custom BBCode tags, placeholders and smilies. 20 | 21 | Features 22 | -------- 23 | 24 | * BBCode parser for rendering any string containing bbcodes 25 | * Tools for handling bbcode contents: templatetags, model field 26 | * Support for custom bbcode tags: 27 | 28 | * Simple custom bbcodes can be defined in the Django admin 29 | * ... or they can be registered using a plugin system by defining some Python classes 30 | * Support for custom bbcode placeholders 31 | * Support for custom smilies and emoticons 32 | 33 | 34 | Using django-precise-bbcode 35 | --------------------------- 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | 40 | getting_started 41 | basic_reference/index 42 | extending_precise_bbcode/index 43 | settings 44 | 45 | 46 | Indices and tables 47 | ================== 48 | 49 | * :ref:`genindex` 50 | * :ref:`modindex` 51 | * :ref:`search` 52 | -------------------------------------------------------------------------------- /docs/basic_reference/rendering_bbcodes.rst: -------------------------------------------------------------------------------- 1 | ################# 2 | Rendering BBCodes 3 | ################# 4 | 5 | BBcode parser 6 | ------------- 7 | 8 | *Django-precise-bbcode* provides a BBCode parser that allows you to transform any textual content containing BBCode tags to the corresponding HTML markup. To do this, just import the ``get_parser`` shortcut and use the ``render`` method of the BBCode parser:: 9 | 10 | from precise_bbcode.bbcode import get_parser 11 | 12 | parser = get_parser() 13 | rendered = parser.render('[b]Hello [u]world![/u][/b]') 14 | 15 | This will store the following HTML into the ``rendered`` variable:: 16 | 17 | Hello world! 18 | 19 | Template tags 20 | ------------- 21 | 22 | The previous parser can also be used in your templates as a template filter or as a template tag after loading ``bbcode_tags``:: 23 | 24 | {% load bbcode_tags %} 25 | {% bbcode entry.bbcode_content %} 26 | {{ "[b]Write some bbcodes![/b]"|bbcode }} 27 | 28 | Doing this will force the BBCode content included in the ``entry.bbcode_content`` field to be converted to HTML. The last statement will output:: 29 | 30 | Write some bbcodes! 31 | 32 | Jinja2 template support 33 | ----------------------- 34 | 35 | *Django-precise-bbcode* supports Jinja 2 templating. You have to add the ``precise_bbcode.jinja2tags.bbcode`` extension to your template extensions if you want to use the *django-precise-bbcode*'s Jinja 2 tags in your project:: 36 | 37 | TEMPLATES = [ 38 | # ... 39 | { 40 | 'BACKEND': 'django.template.backends.jinja2.Jinja2', 41 | 'APP_DIRS': True, 42 | 'OPTIONS': { 43 | 'extensions': [ 44 | 'precise_bbcode.jinja2tags.bbcode', 45 | ], 46 | }, 47 | } 48 | ] 49 | 50 | The BBCode parser can then be used into your Jinja 2 templates as a function or as a template filter:: 51 | 52 | {{ bbcode("[b]Write some bbcodes![/b]") }} 53 | {{ "[b]Write some bbcodes![/b]"|bbcode }} 54 | -------------------------------------------------------------------------------- /precise_bbcode/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.admin import actions 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from .bbcode import get_parser 6 | from .models import BBCodeTag 7 | from .models import SmileyTag 8 | 9 | 10 | class BBCodeTagAdmin(admin.ModelAdmin): 11 | list_display = ('tag_name', 'tag_definition', 'html_replacement') 12 | list_display_links = ('tag_name', 'tag_definition', 'html_replacement') 13 | fieldsets = ( 14 | (None, { 15 | 'fields': ( 16 | 'tag_definition', 'html_replacement', 'helpline', 'standalone', 'display_on_editor') 17 | }), 18 | (_('Advanced options'), { 19 | 'classes': ('collapse',), 20 | 'fields': ( 21 | 'newline_closes', 22 | 'same_tag_closes', 23 | 'end_tag_closes', 24 | 'transform_newlines', 25 | 'render_embedded', 26 | 'escape_html', 27 | 'replace_links', 28 | 'strip', 29 | 'swallow_trailing_newline' 30 | ) 31 | }), 32 | ) 33 | 34 | def get_actions(self, request): 35 | actions = super(BBCodeTagAdmin, self).get_actions(request) 36 | actions['delete_selected'] = ( 37 | BBCodeTagAdmin.delete_selected, 38 | 'delete_selected', 39 | _('Delete selected %(verbose_name_plural)s'), 40 | ) 41 | return actions 42 | 43 | def delete_selected(self, request, queryset): 44 | parser = get_parser() 45 | tag_names = list(queryset.values_list('tag_name', flat=True)) 46 | response = actions.delete_selected(self, request, queryset) 47 | 48 | if response is None: 49 | [parser.bbcodes.pop(n) for n in tag_names] 50 | 51 | return response 52 | 53 | 54 | class SmileyTagAdmin(admin.ModelAdmin): 55 | list_display = ('code', 'emotion') 56 | list_display_links = ('code', 'emotion') 57 | 58 | 59 | admin.site.register(BBCodeTag, BBCodeTagAdmin) 60 | admin.site.register(SmileyTag, SmileyTagAdmin) 61 | -------------------------------------------------------------------------------- /tests/integration/test_jinja2tags.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | 5 | @pytest.mark.skipif(django.VERSION < (1, 8), 6 | reason="requires django>=1.8") 7 | @pytest.mark.django_db 8 | class TestBbcodeJinja2Tags(object): 9 | BBCODE_FILTER_EXPRESSIONS_TESTS = ( 10 | ( 11 | '{{ "[b]hello world![/b]"|bbcode }}', 12 | 'hello world!', 13 | ), 14 | ( 15 | '{{ "[b]Write some bbcodes![/b] The [i]current[/i] text was generated by using the ' 16 | 'bbcode filter..."|bbcode }}', 17 | 'Write some bbcodes! The current text was generated by using ' 18 | 'the bbcode filter...', 19 | ), 20 | ) 21 | 22 | BBCODE_TAG_EXPRESSIONS_TESTS = ( 23 | ( 24 | '{{ bbcode("This [b]one[/b] was generated using the [i][color=green]bbcode[/color][/i] ' 25 | 'template tag.") }}', 26 | 'This one was generated using the ' 27 | 'bbcode template tag.', 28 | ), 29 | ( 30 | '{% set renderedvar = bbcode("Hello [u]world![/u]") %}' 31 | '{{ renderedvar }}', 32 | 'Hello world!', 33 | ), 34 | ( 35 | "{{ bbcode('[i]a \"small\" test[/i]') }}", 36 | "a "small" test", 37 | ) 38 | ) 39 | 40 | def setup_method(self, method): 41 | from django.template import engines 42 | self.engine = engines['jinja2'] 43 | 44 | def test_provide_a_functional_bbcode_filter(self): 45 | # Run & check 46 | for template_content, expected_html_text in self.BBCODE_FILTER_EXPRESSIONS_TESTS: 47 | t = self.engine.from_string(template_content) 48 | rendered = t.render({}) 49 | assert rendered == expected_html_text 50 | 51 | def test_provide_a_functional_bbcode_tag(self): 52 | # Run & check 53 | for template_content, expected_html_text in self.BBCODE_TAG_EXPRESSIONS_TESTS: 54 | t = self.engine.from_string(template_content) 55 | rendered = t.render({}) 56 | assert rendered == expected_html_text 57 | -------------------------------------------------------------------------------- /tests/unit/test_smilies.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.core.files import File 6 | 7 | from precise_bbcode.bbcode import BBCodeParserLoader 8 | from precise_bbcode.bbcode import get_parser 9 | from precise_bbcode.models import SmileyTag 10 | 11 | 12 | @pytest.mark.django_db 13 | class TestSmiley(object): 14 | SMILIES_TESTS = ( 15 | ( 16 | ':test:', 17 | '' 19 | ), 20 | ( 21 | '[list][*]:test: hello\n[/list]', 22 | '
  • hello
' 25 | ), 26 | ( 27 | '[quote]hello :test:[/quote]', 28 | '
hello
' 31 | ), 32 | ('[code]hello :test:[/code]', 'hello :test:'), 33 | ) 34 | 35 | def create_smilies(self): 36 | self.parser = get_parser() 37 | self.parser_loader = BBCodeParserLoader(parser=self.parser) 38 | # Set up an image used for doing smilies tests 39 | f = open(settings.MEDIA_ROOT + '/icon_e_wink.gif', 'rb') 40 | image_file = File(f) 41 | self.image = image_file 42 | # Set up a smiley tag 43 | smiley = SmileyTag() 44 | smiley.code = ':test:' 45 | smiley.image.save('icon_e_wink.gif', self.image) 46 | smiley.save() 47 | self.parser_loader.init_bbcode_smilies() 48 | 49 | def teardown_method(self, method): 50 | self.image.close() 51 | shutil.rmtree(settings.MEDIA_ROOT + '/precise_bbcode') 52 | 53 | def test_can_render_valid_smilies(self): 54 | # Setup 55 | self.create_smilies() 56 | # Run & check 57 | for bbcodes_text, expected_html_text in self.SMILIES_TESTS: 58 | result = self.parser.render(bbcodes_text) 59 | assert result == expected_html_text 60 | -------------------------------------------------------------------------------- /example_project/test_messages/bbcode_tags.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from precise_bbcode.bbcode.tag import BBCodeTag 4 | from precise_bbcode.tag_pool import tag_pool 5 | 6 | 7 | color_re = re.compile(r'^([a-z]+|#[0-9abcdefABCDEF]{3,6})$') 8 | 9 | 10 | class SubTag(BBCodeTag): 11 | name = 'sub' 12 | 13 | def render(self, value, option=None, parent=None): 14 | return '%s' % value 15 | 16 | 17 | class PreTag(BBCodeTag): 18 | name = 'pre' 19 | render_embedded = False 20 | 21 | def render(self, value, option=None, parent=None): 22 | return '
%s
' % value 23 | 24 | 25 | class SizeTag(BBCodeTag): 26 | name = 'size' 27 | definition_string = '[size={RANGE=4,7}]{TEXT}[/size]' 28 | format_string = '{TEXT}' 29 | 30 | 31 | class FruitTag(BBCodeTag): 32 | name = 'fruit' 33 | definition_string = '[fruit]{CHOICE=tomato,orange,apple}[/fruit]' 34 | format_string = '
{CHOICE=tomato,orange,apple}
' 35 | 36 | 37 | class PhoneLinkTag(BBCodeTag): 38 | name = 'phone' 39 | definition_string = '[phone]{PHONENUMBER}[/phone]' 40 | format_string = '{PHONENUMBER}' 41 | 42 | def render(self, value, option=None, parent=None): 43 | href = 'tel:{}'.format(value) 44 | return '{1}'.format(href, value) 45 | 46 | 47 | class StartsWithATag(BBCodeTag): 48 | name = 'startswitha' 49 | definition_string = '[startswitha]{STARTSWITH=a}[/startswitha]' 50 | format_string = '{STARTSWITH=a}' 51 | 52 | 53 | class RoundedBBCodeTag(BBCodeTag): 54 | name = 'rounded' 55 | 56 | class Options: 57 | strip = False 58 | 59 | def render(self, value, option=None, parent=None): 60 | if option and re.search(color_re, option) is not None: 61 | return '
{}
'.format(option, value) 62 | return '
{}
'.format(value) 63 | 64 | 65 | tag_pool.register_tag(SubTag) 66 | tag_pool.register_tag(PreTag) 67 | tag_pool.register_tag(SizeTag) 68 | tag_pool.register_tag(FruitTag) 69 | tag_pool.register_tag(PhoneLinkTag) 70 | tag_pool.register_tag(StartsWithATag) 71 | tag_pool.register_tag(RoundedBBCodeTag) 72 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TEST_ROOT = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | 7 | DEBUG = False 8 | TEMPLATE_DEBUG = False 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:' 14 | } 15 | } 16 | 17 | TEMPLATES = [ 18 | { 19 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 20 | 'APP_DIRS': True, 21 | 'DIRS': [], 22 | 'OPTIONS': { 23 | 'context_processors': ( 24 | 'django.contrib.auth.context_processors.auth', 25 | 'django.template.context_processors.debug', 26 | 'django.template.context_processors.i18n', 27 | 'django.template.context_processors.media', 28 | 'django.template.context_processors.static', 29 | 'django.template.context_processors.tz', 30 | 'django.contrib.messages.context_processors.messages', 31 | ), 32 | }, 33 | }, 34 | { 35 | 'BACKEND': 'django.template.backends.jinja2.Jinja2', 36 | 'APP_DIRS': True, 37 | 'DIRS': [], 38 | 'OPTIONS': { 39 | 'extensions': [ 40 | 'precise_bbcode.jinja2tags.bbcode', 41 | ], 42 | }, 43 | }, 44 | ] 45 | 46 | INSTALLED_APPS = ( 47 | 'django.contrib.auth', 48 | 'django.contrib.contenttypes', 49 | 'django.contrib.messages', 50 | 'django.contrib.sessions', 51 | 'django.contrib.sites', 52 | 'precise_bbcode', 53 | 'tests', 54 | 'django.contrib.admin', 55 | 'tests._testsite.dummyapp01', 56 | 'tests._testsite.dummyapp02.apps.DummyAppConfig', 57 | ) 58 | 59 | ROOT_URLCONF = 'tests._testsite.urls' 60 | 61 | MIDDLEWARE = ( 62 | 'django.middleware.security.SecurityMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'django.middleware.common.CommonMiddleware', 65 | 'django.middleware.csrf.CsrfViewMiddleware', 66 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 67 | 'django.contrib.messages.middleware.MessageMiddleware', 68 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 69 | ) 70 | 71 | ADMINS = ('admin@example.com',) 72 | 73 | STATIC_URL = '/static/' 74 | 75 | MEDIA_ROOT = os.path.join(TEST_ROOT, '_testdata/media/') 76 | 77 | SITE_ID = 1 78 | 79 | # Setting this explicitly prevents Django 1.7+ from showing a 80 | # warning regarding a changed default test runner. The test 81 | # suite is run with py.test, so it does not matter. 82 | SILENCED_SYSTEM_CHECKS = ['1_6.W001'] 83 | 84 | SECRET_KEY = 'key' 85 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/defaults/placeholder.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import URLValidator 5 | 6 | from precise_bbcode.bbcode.placeholder import BBCodePlaceholder 7 | 8 | 9 | __all__ = [ 10 | 'UrlBBCodePlaceholder', 11 | 'EmailBBCodePlaceholder', 12 | 'TextBBCodePlaceholder', 13 | 'SimpleTextBBCodePlaceholder', 14 | 'ColorBBCodePlaceholder', 15 | 'NumberBBCodePlaceholder', 16 | 'RangeBBCodePlaceholder', 17 | 'ChoiceBBCodePlaceholder', 18 | ] 19 | 20 | 21 | # Placeholder regexes 22 | _email_re = re.compile(r'(\w+[.|\w])*@\[?(\w+[.])*\w+\]?', flags=re.I) 23 | _text_re = re.compile(r'^\s*([\w]+)|([\w]+\S*)\s*', flags=(re.U | re.M)) 24 | _simpletext_re = re.compile(r'^[a-zA-Z0-9-+.,_ ]+$') 25 | _color_re = re.compile(r'^([a-z]+|#[0-9abcdefABCDEF]{3,6})$') 26 | _number_re = re.compile(r'^[+-]?\d+(?:(\.|,)\d+)?$') 27 | 28 | 29 | class UrlBBCodePlaceholder(BBCodePlaceholder): 30 | name = 'url' 31 | 32 | def validate(self, content, extra_context=None): 33 | v = URLValidator() 34 | try: 35 | v(content) 36 | except ValidationError: 37 | return False 38 | return True 39 | 40 | 41 | class EmailBBCodePlaceholder(BBCodePlaceholder): 42 | name = 'email' 43 | pattern = _email_re 44 | 45 | 46 | class TextBBCodePlaceholder(BBCodePlaceholder): 47 | name = 'text' 48 | pattern = _text_re 49 | 50 | 51 | class SimpleTextBBCodePlaceholder(BBCodePlaceholder): 52 | name = 'simpletext' 53 | pattern = _simpletext_re 54 | 55 | 56 | class ColorBBCodePlaceholder(BBCodePlaceholder): 57 | name = 'color' 58 | pattern = _color_re 59 | 60 | 61 | class NumberBBCodePlaceholder(BBCodePlaceholder): 62 | name = 'number' 63 | pattern = _number_re 64 | 65 | 66 | class RangeBBCodePlaceholder(BBCodePlaceholder): 67 | name = 'range' 68 | 69 | def validate(self, content, extra_context): 70 | try: 71 | value = float(content) 72 | except ValueError: 73 | return False 74 | 75 | try: 76 | min_content, max_content = extra_context.split(',') 77 | min_value, max_value = float(min_content), float(max_content) 78 | except ValueError: 79 | return False 80 | 81 | if not (value >= min_value and value <= max_value): 82 | return False 83 | 84 | return True 85 | 86 | 87 | class ChoiceBBCodePlaceholder(BBCodePlaceholder): 88 | name = 'choice' 89 | 90 | def validate(self, content, extra_context): 91 | choices = extra_context.split(',') 92 | return content in choices 93 | -------------------------------------------------------------------------------- /precise_bbcode/placeholder_pool.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from .bbcode.placeholder import BBCodePlaceholder 4 | from .core.loading import load 5 | 6 | 7 | class PlaceholderAlreadyRegistered(Exception): 8 | pass 9 | 10 | 11 | class PlaceholderNotRegistered(Exception): 12 | pass 13 | 14 | 15 | class PlaceholderPool(object): 16 | """ 17 | BBCode placeholders are registered with the PlaceholderPool using the register() method. It 18 | makes them available to the BBCode parser. 19 | """ 20 | 21 | def __init__(self): 22 | self.placeholders = {} 23 | self.discovered = False 24 | 25 | def discover_placeholders(self): 26 | if self.discovered: 27 | return 28 | self.discovered = True 29 | load('bbcode_placeholders') 30 | 31 | def register_placeholder(self, placeholder): 32 | """ 33 | Registers the given placeholder(s). 34 | If a placeholder appears to be already registered, a PlaceholderAlreadyRegistered exception 35 | will be raised. 36 | """ 37 | # A registered placeholder must be a subclass of BBCodePlaceholder 38 | if not issubclass(placeholder, BBCodePlaceholder): 39 | raise ImproperlyConfigured( 40 | 'BBCode Placeholders must be subclasses of BBCodePlaceholder, ' 41 | '{!r} is not'.format(placeholder) 42 | ) 43 | 44 | # Two placeholders with the same names can't be registered 45 | placeholder_name = placeholder.name 46 | if placeholder_name in self.placeholders: 47 | raise PlaceholderAlreadyRegistered( 48 | 'Cannot register {!r}, a placeholder with this name ({!r}) ' 49 | 'is already registered'.format( 50 | placeholder, placeholder_name) 51 | ) 52 | 53 | self.placeholders[placeholder_name] = placeholder 54 | 55 | def unregister_placeholder(self, placeholder): 56 | """ 57 | Unregister the given placeholder(s). 58 | If a placeholder appears to be not registered, a PlaceholderNotRegistered exception will be 59 | raised. 60 | """ 61 | placeholder_name = placeholder.name 62 | if placeholder_name not in self.placeholders: 63 | raise PlaceholderNotRegistered( 64 | 'The placeholder {!r} is not registered'.format(placeholder) 65 | ) 66 | del self.placeholders[placeholder_name] 67 | 68 | def get_placeholders(self): 69 | self.discover_placeholders() 70 | return self.placeholders.values() 71 | 72 | 73 | placeholder_pool = PlaceholderPool() 74 | -------------------------------------------------------------------------------- /example_project/example_project/jinja2/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | 6 | 7 | {% block title %}Precise BBCode Test Site{% endblock %} 8 | 9 | 10 | 11 | 12 | 15 | {% endblock head %} 16 | 17 | {% block css %} 18 | 19 | 20 | {% endblock css %} 21 | 22 | {% block extra_css %} 23 | {% endblock extra_css %} 24 | 25 | 26 | 27 | {% block js %} 28 | 29 | {% endblock js %} 30 |
31 | {% block header %} 32 | 45 | {% endblock header %} 46 |




47 |
48 |
49 |
50 | {% block content %} 51 | {% endblock content %} 52 |
53 |
54 |
55 | 56 |
57 |
58 | 59 | {% block extra_js %} 60 | {% endblock extra_js %} 61 | 62 | 63 | -------------------------------------------------------------------------------- /precise_bbcode/tag_pool.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from .bbcode.tag import BBCodeTag 4 | from .conf import settings as bbcode_settings 5 | from .core.loading import load 6 | from .models import BBCodeTag as DbBBCodeTag 7 | 8 | 9 | class TagAlreadyRegistered(Exception): 10 | pass 11 | 12 | 13 | class TagAlreadyCreated(Exception): 14 | pass 15 | 16 | 17 | class TagNotRegistered(Exception): 18 | pass 19 | 20 | 21 | class TagPool(object): 22 | """ 23 | BBCode tags are registered with the TagPool using the register() method. It makes them 24 | available to the BBCode parser. 25 | """ 26 | 27 | def __init__(self): 28 | self.tags = {} 29 | self.discovered = False 30 | 31 | def discover_tags(self): 32 | if self.discovered: 33 | return 34 | self.discovered = True 35 | load('bbcode_tags') 36 | 37 | def register_tag(self, tag): 38 | """ 39 | Registers the given tag(s). 40 | If a tag appears to be already registered, a TagAlreadyRegistered exception will be raised. 41 | """ 42 | # A registered tag must be a subclass of BBCodeTag 43 | if not issubclass(tag, BBCodeTag): 44 | raise ImproperlyConfigured( 45 | 'BBCode Tags must be subclasses of BBCodeTag, {!r} is not'.format(tag) 46 | ) 47 | 48 | # Two tag with the same names can't be registered 49 | tag_name = tag.name 50 | if tag_name in self.tags: 51 | raise TagAlreadyRegistered( 52 | 'Cannot register {!r}, a tag with this name ({!r}) ' 53 | 'is already registered'.format(tag, tag_name) 54 | ) 55 | 56 | # The tag cannot be registered if it is already stored as bbcode tag in the database 57 | bbcode_tag_qs = DbBBCodeTag.objects.filter(tag_name=tag_name) 58 | if bbcode_tag_qs.exists() and bbcode_settings.BBCODE_ALLOW_CUSTOM_TAGS: 59 | raise TagAlreadyCreated( 60 | """Cannot register {!r}, a tag with this name ({!r}) is 61 | already stored in your database: {}""".format(tag, tag_name, bbcode_tag_qs[0]) 62 | ) 63 | 64 | self.tags[tag_name] = tag 65 | 66 | def unregister_tag(self, tag): 67 | """ 68 | Unregister the given tag(s). 69 | If a tag appears to be not registered, a TagNotRegistered exception will be raised. 70 | """ 71 | tag_name = tag.name 72 | if tag_name not in self.tags: 73 | raise TagNotRegistered( 74 | 'The tag {!r} is not registered'.format(tag) 75 | ) 76 | del self.tags[tag_name] 77 | 78 | def get_tags(self): 79 | self.discover_tags() 80 | return self.tags.values() 81 | 82 | 83 | tag_pool = TagPool() 84 | -------------------------------------------------------------------------------- /tests/integration/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import Context 3 | from django.template import TemplateSyntaxError 4 | from django.template.base import Template 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestBbcodeTemplateTags(object): 9 | BBCODE_FILTER_EXPRESSIONS_TESTS = ( 10 | ( 11 | '{{ "[b]hello world![/b]"|bbcode }}', 12 | 'hello world!', 13 | ), 14 | ( 15 | '{% with "[b]Write some bbcodes![/b] The [i]current[/i] text was generated by using ' 16 | 'the bbcode filter..." as testval %}' 17 | '{{ testval|bbcode }}' 18 | '{% endwith %}', 19 | 'Write some bbcodes! The current text was generated by using ' 20 | 'the bbcode filter...', 21 | ), 22 | ) 23 | 24 | BBCODE_TAG_EXPRESSIONS_TESTS = ( 25 | ( 26 | '{% bbcode "This [b]one[/b] was generated using the [i][color=green]bbcode[/color][/i] ' 27 | 'template tag." %}', 28 | 'This one was generated using the ' 29 | 'bbcode template tag.', 30 | ), 31 | ( 32 | '{% bbcode "Hello [u]world![/u]" as renderedvar %}' 33 | '{{ renderedvar }}', 34 | 'Hello world!', 35 | ), 36 | ( 37 | "{% bbcode '[i]a \"small\" test[/i]' %}", 38 | "a "small" test", 39 | ) 40 | ) 41 | 42 | BBCODE_TAG_ERRONEOUS_EXPRESSIONS_TESTS = ( 43 | '{% bbcode %}', 44 | '{% bbcode "[b]hello world![/b]" as var as %}', 45 | '{% bbcode "[b]hello world![/b]" as %}', 46 | '{% bbcode "[b]hello world![/b]" bad %}', 47 | ) 48 | 49 | def setup_method(self, method): 50 | self.loadstatement = '{% load bbcode_tags %}' 51 | 52 | def test_provide_a_functional_bbcode_filter(self): 53 | # Run & check 54 | for template_content, expected_html_text in self.BBCODE_FILTER_EXPRESSIONS_TESTS: 55 | t = Template(self.loadstatement + template_content) 56 | rendered = t.render(Context()) 57 | assert rendered == expected_html_text 58 | 59 | def test_provide_a_functional_bbcode_tag(self): 60 | # Run & check 61 | for template_content, expected_html_text in self.BBCODE_TAG_EXPRESSIONS_TESTS: 62 | t = Template(self.loadstatement + template_content) 63 | rendered = t.render(Context()) 64 | assert rendered == expected_html_text 65 | 66 | def test_should_raise_in_case_of_erroneous_syntax(self): 67 | # Run & check 68 | for template_content in self.BBCODE_TAG_ERRONEOUS_EXPRESSIONS_TESTS: 69 | with pytest.raises(TemplateSyntaxError): 70 | Template(self.loadstatement + template_content) 71 | -------------------------------------------------------------------------------- /precise_bbcode/templatetags/bbcode_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template import Node 3 | from django.template import TemplateSyntaxError 4 | from django.template import Variable 5 | from django.template.defaultfilters import stringfilter 6 | from django.utils.safestring import mark_safe 7 | 8 | from precise_bbcode.shortcuts import render_bbcodes 9 | 10 | 11 | register = template.Library() 12 | 13 | 14 | class BBCodeNode(Node): 15 | def __init__(self, filter_expression, asvar=None): 16 | self.filter_expression = filter_expression 17 | self.asvar = asvar 18 | if isinstance(self.filter_expression.var, str): 19 | self.filter_expression.var = Variable("'{!s}'".format(self.filter_expression.var)) 20 | 21 | def render(self, context): 22 | output = self.filter_expression.resolve(context) 23 | output = output.resolve(context) if hasattr(output, 'resolve') else output 24 | value = mark_safe(render_bbcodes(output)) 25 | if self.asvar: 26 | context[self.asvar] = value 27 | return '' 28 | else: 29 | return value 30 | 31 | 32 | @register.filter(is_safe=True) 33 | @stringfilter 34 | def bbcode(value): 35 | return render_bbcodes(value) 36 | 37 | 38 | @register.tag('bbcode') 39 | def do_bbcode_rendering(parser, token): 40 | """ 41 | This will render a string containing bbcodes to the corresponding HTML markup. 42 | 43 | Usage:: 44 | 45 | {% bbcode "[b]hello world![/b]" %} 46 | 47 | You can use variables instead of constant strings to render bbcode stuff:: 48 | 49 | {% bbcode contentvar %} 50 | 51 | It is possible to store the rendered string into a variable:: 52 | 53 | {% bbcode "[b]hello world![/b]" as renderedvar %} 54 | """ 55 | bits = token.split_contents() 56 | if len(bits) < 2: 57 | raise TemplateSyntaxError('\'{0}\' takes at least one argument'.format(bits[0])) 58 | value = parser.compile_filter(bits[1]) 59 | 60 | remaining = bits[2:] 61 | asvar = None 62 | seen = set() 63 | 64 | while remaining: 65 | option = remaining.pop(0) 66 | if option in seen: 67 | raise TemplateSyntaxError( 68 | 'The \'{0}\' option was specified more than once.'.format(option)) 69 | elif option == 'as': 70 | try: 71 | var_value = remaining.pop(0) 72 | except IndexError: 73 | msg = 'No argument provided to the \'{0}\' tag for the as option.'.format(bits[0]) 74 | raise TemplateSyntaxError(msg) 75 | asvar = var_value 76 | else: 77 | raise TemplateSyntaxError( 78 | 'Unknown argument for \'{0}\' tag: \'{1}\'. The only options ' 79 | 'available is \'as VAR\'.'.format(bits[0], option)) 80 | seen.add(option) 81 | 82 | return BBCodeNode(value, asvar) 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_PACKAGE := precise_bbcode 2 | TEST_PACKAGE := tests 3 | 4 | 5 | init: 6 | poetry install 7 | 8 | 9 | # DEVELOPMENT 10 | # ~~~~~~~~~~~ 11 | # The following rules can be used during development in order to compile staticfiles, generate 12 | # locales, build documentation, etc. 13 | # -------------------------------------------------------------------------------------------------- 14 | 15 | .PHONY: c console 16 | ## Alias of "console". 17 | c: console 18 | ## Launch a development console. 19 | console: 20 | poetry run ipython 21 | 22 | .PHONY: docs 23 | ## Builds the documentation. 24 | docs: 25 | cd docs && rm -rf _build && mkdir _build && poetry run make html 26 | 27 | 28 | # QUALITY ASSURANCE 29 | # ~~~~~~~~~~~~~~~~~ 30 | # The following rules can be used to check code quality, import sorting, etc. 31 | # -------------------------------------------------------------------------------------------------- 32 | 33 | .PHONY: qa 34 | ## Trigger all quality assurance checks. 35 | qa: lint isort 36 | 37 | .PHONY: lint 38 | ## Trigger Python code quality checks (flake8). 39 | lint: 40 | poetry run flake8 41 | 42 | .PHONY: isort 43 | ## Check Python imports sorting. 44 | isort: 45 | poetry run isort --check-only --diff $(PROJECT_PACKAGE) $(TEST_PACKAGE) 46 | 47 | 48 | # TESTING 49 | # ~~~~~~~ 50 | # The following rules can be used to trigger tests execution and produce coverage reports. 51 | # -------------------------------------------------------------------------------------------------- 52 | 53 | .PHONY: t tests 54 | ## Alias of "tests". 55 | t: tests 56 | ## Run the Python test suite. 57 | tests: 58 | poetry run py.test 59 | 60 | .PHONY: coverage 61 | ## Collects code coverage data. 62 | coverage: 63 | poetry run py.test --cov-report term-missing --cov $(PROJECT_PACKAGE) 64 | 65 | .PHONY: spec 66 | ## Run the tests in "spec" mode. 67 | spec: 68 | poetry run py.test --spec -p no:sugar 69 | 70 | 71 | # MAKEFILE HELPERS 72 | # ~~~~~~~~~~~~~~~~ 73 | # The following rules can be used to list available commands and to display help messages. 74 | # -------------------------------------------------------------------------------------------------- 75 | 76 | # COLORS 77 | GREEN := $(shell tput -Txterm setaf 2) 78 | YELLOW := $(shell tput -Txterm setaf 3) 79 | WHITE := $(shell tput -Txterm setaf 7) 80 | RESET := $(shell tput -Txterm sgr0) 81 | 82 | .PHONY: help 83 | ## Print Makefile help. 84 | help: 85 | @echo '' 86 | @echo 'Usage:' 87 | @echo ' ${YELLOW}make${RESET} ${GREEN}${RESET}' 88 | @echo '' 89 | @echo 'Actions:' 90 | @awk '/^[a-zA-Z\-\_0-9]+:/ { \ 91 | helpMessage = match(lastLine, /^## (.*)/); \ 92 | if (helpMessage) { \ 93 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 94 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 95 | printf " ${YELLOW}%-$(TARGET_MAX_CHAR_NUM)-30s${RESET}\t${GREEN}%s${RESET}\n", helpCommand, helpMessage; \ 96 | } \ 97 | } \ 98 | { lastLine = $$0 }' $(MAKEFILE_LIST) | sort -t'|' -sk1,1 99 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/placeholder.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from precise_bbcode.bbcode.exceptions import InvalidBBCodePlaholder 4 | from precise_bbcode.core.compat import pattern_type 5 | from precise_bbcode.core.compat import with_metaclass 6 | 7 | 8 | class BBCodePlaceholderBase(type): 9 | """ 10 | Metaclass for all BBCode placehplders. 11 | This metaclass ensure that the BBCode placeholders subclasses have the required values 12 | and proceed to some validations. 13 | """ 14 | def __new__(cls, name, bases, attrs): 15 | super_new = super(BBCodePlaceholderBase, cls).__new__ 16 | parents = [base for base in bases if isinstance(base, BBCodePlaceholderBase)] 17 | 18 | if not parents: 19 | # Stop here if we are considering the top-level class to which the 20 | # current metaclass was applied and not one of its subclasses. 21 | # eg. BBCodeTag 22 | return super_new(cls, name, bases, attrs) 23 | 24 | # Construct the BBCode placeholder class 25 | new_placeholder = super_new(cls, name, bases, attrs) 26 | 27 | # Validates the placeholder name 28 | if not hasattr(new_placeholder, 'name'): 29 | raise InvalidBBCodePlaholder( 30 | 'BBCodePlaceholderBase subclasses must have a \'name\' attribute' 31 | ) 32 | if not new_placeholder.name: 33 | raise InvalidBBCodePlaholder( 34 | 'The \'name\' attribute associated with InvalidBBCodePlaholder subclasses ' 35 | 'cannot be None' 36 | ) 37 | if not re.match('^[\w]+$', new_placeholder.name): 38 | raise InvalidBBCodePlaholder( 39 | """The \'name\' attribute associated with {!r} is not valid: a placeholder name must be strictly 40 | composed of alphanumeric character""".format(name) 41 | ) 42 | 43 | # Validates the placeholder pattern if present 44 | if new_placeholder.pattern and not isinstance(new_placeholder.pattern, pattern_type): 45 | raise InvalidBBCodePlaholder( 46 | """The \'pattern\' attribute associated with {!r} is not valid: a placeholder pattern must be an 47 | instance of a valid regex type""".format(name) 48 | ) 49 | 50 | return new_placeholder 51 | 52 | 53 | class BBCodePlaceholder(with_metaclass(BBCodePlaceholderBase)): 54 | name = None 55 | pattern = None 56 | 57 | def validate(self, content, extra_context=None): 58 | """ 59 | The validate function is used to check whether the given content is valid 60 | according to the placeholder definition associated to it. 61 | 62 | content 63 | The content used to fill the placeholder that must be validated. 64 | extra_context 65 | The extra context of the placeholder if defined in a tag definition. 66 | 67 | Note that the extra context of a placeholder always corresponds to the string 68 | positioned after the '='' sign in the definition of the placeholder in the 69 | considered BBCode tag. 70 | For example, consider the following placeholder definition: 71 | 72 | {TEXT1=4,3} 73 | 74 | 'TEXT' is the name of the placeholder while '4,3' is the extra context of the 75 | placeholder. This extra context could be used to perform extra validation. 76 | 77 | The default implementation of the 'validate' method will use the regex pattern 78 | provided by the 'pattern' attribute to validate any passed content. Note that this 79 | default behavior can be updated with another logic by overriding this method. 80 | """ 81 | if self.pattern: 82 | # This is the default case: validates the passed content by using a 83 | # specified regex pattern. 84 | return re.search(self.pattern, content) 85 | 86 | # In any other case a NotImplementedError is raised to ensure 87 | # that any subclasses must override this method 88 | raise NotImplementedError 89 | -------------------------------------------------------------------------------- /tests/unit/test_fields.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.core.files import File 7 | from django.utils.safestring import SafeText 8 | 9 | from precise_bbcode.fields import BBCodeContent 10 | from precise_bbcode.models import SmileyTag 11 | from tests.models import DummyMessage 12 | 13 | 14 | @pytest.mark.django_db 15 | class TestBbcodeTextField(object): 16 | BBCODE_FIELDS_TESTS = ( 17 | ('[b]hello [u]world![/u][/b]', 'hello world!'), 18 | ('[url=http://google.com]goto google[/url]', 'goto google'), 19 | ('[b]hello [u]worlsd![/u][/b]', 'hello worlsd!'), 20 | ('[b]안녕하세요[/b]', '안녕하세요'), 21 | ) 22 | 23 | def test_accepts_none_values(self): 24 | # Setup 25 | message = DummyMessage() 26 | message.content = None 27 | # Run 28 | message.save() 29 | # Check 30 | assert message.content is None 31 | rendered = hasattr(message.content, 'rendered') 32 | assert not rendered 33 | 34 | def test_can_save_both_raw_and_rendered_data(self): 35 | # Run & check 36 | for bbcodes_text, expected_html_text in self.BBCODE_FIELDS_TESTS: 37 | message = DummyMessage() 38 | message.content = bbcodes_text 39 | message.save() 40 | assert message.content.rendered == expected_html_text 41 | 42 | def test_uses_a_valid_descriptor_protocol(self): 43 | # Setup 44 | message = DummyMessage() 45 | message.content = None 46 | message.save() 47 | bbcode_content = BBCodeContent('[b]hello world![/b]') 48 | # Run 49 | message.content = bbcode_content 50 | message.save() 51 | # Check 52 | assert message.content.rendered == 'hello world!' 53 | 54 | def test_rendered_values_are_safe_strings(self): 55 | # Setup 56 | message = DummyMessage() 57 | message.content = None 58 | message.save() 59 | bbcode_content = BBCodeContent('[b]hello world![/b]') 60 | # Run 61 | message.content = bbcode_content 62 | message.save() 63 | # Check 64 | assert isinstance(message.content.rendered, SafeText) 65 | 66 | 67 | @pytest.mark.django_db 68 | class TestSmileyCodeField(object): 69 | SMILIES_FIELDS_TESTS = ( 70 | ';-)', 71 | ':lol:', 72 | '>_<', 73 | '><', 74 | ':emotion:', 75 | '(-_-)', 76 | '(<_>)', 77 | ) 78 | 79 | ERRONEOUS_SMILIES_FIELS_TESTS = ( 80 | 'text with some spaces', 81 | ':i\'m happy:', 82 | ) 83 | 84 | def setup_method(self, method): 85 | # Set up an image used for doing smilies tests 86 | f = open(settings.MEDIA_ROOT + '/icon_e_wink.gif', 'rb') 87 | image_file = File(f) 88 | self.image = image_file 89 | 90 | def teardown_method(self, method): 91 | self.image.close() 92 | shutil.rmtree(settings.MEDIA_ROOT + '/precise_bbcode') 93 | 94 | def test_can_save_valid_smilies(self): 95 | # Run & check 96 | for smiley_code in self.SMILIES_FIELDS_TESTS: 97 | smiley = SmileyTag() 98 | smiley.code = smiley_code 99 | self.image.open() # Re-open the ImageField 100 | smiley.image.save('icon_e_wink.gif', self.image) 101 | try: 102 | smiley.full_clean() 103 | except ValidationError: 104 | pytest.xfail('The following smiley code failed to validate: {}'.format(smiley_code)) 105 | 106 | def test_should_not_save_erroneous_smilies(self): 107 | # Run & check 108 | for smiley_code in self.ERRONEOUS_SMILIES_FIELS_TESTS: 109 | smiley = SmileyTag() 110 | smiley.code = smiley_code 111 | self.image.open() # Re-open the ImageField 112 | smiley.image.save('icon_e_wink.gif', self.image) 113 | with pytest.raises(ValidationError): 114 | smiley.full_clean() 115 | -------------------------------------------------------------------------------- /precise_bbcode/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | import precise_bbcode.fields 7 | from precise_bbcode.conf.settings import SMILIES_UPLOAD_TO 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='BBCodeTag', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('tag_name', models.SlugField(unique=True, max_length=20, verbose_name='BBCode tag name')), 21 | ('tag_definition', models.TextField(verbose_name='Tag definition')), 22 | ('html_replacement', models.TextField(verbose_name='Replacement HTML code')), 23 | ('newline_closes', models.BooleanField(default=False, help_text='Set this option to force the closing of this tag after a newline', verbose_name='Newline closing')), 24 | ('same_tag_closes', models.BooleanField(default=False, help_text='Set this option to force the closing of this tag after the beginning of a similar tag', verbose_name='Same tag closing')), 25 | ('end_tag_closes', models.BooleanField(default=False, help_text='Set this option to force the closing of this tag after the end of another tag', verbose_name='End tag closing')), 26 | ('standalone', models.BooleanField(default=False, help_text='Set this option if this tag does not have a closing tag', verbose_name='Standalone tag')), 27 | ('transform_newlines', models.BooleanField(default=True, help_text='Set this option to convert any line break to the equivalent markup', verbose_name='Transform line breaks')), 28 | ('render_embedded', models.BooleanField(default=True, help_text='Set this option to force the tags embedded in this tag to be rendered', verbose_name='Render embedded tags')), 29 | ('escape_html', models.BooleanField(default=True, help_text='Set this option to escape HTML characters (<, >, and &) inside this tag', verbose_name='Escape HTML characters')), 30 | ('replace_links', models.BooleanField(default=True, help_text='Set this option to replace URLs with link markups inside this tag', verbose_name='Replace links')), 31 | ('strip', models.BooleanField(default=False, help_text='Set this option to strip leading and trailing whitespace inside this tag', verbose_name='Strip leading and trailing whitespace')), 32 | ('swallow_trailing_newline', models.BooleanField(default=False, help_text='Set this option to swallow the first trailing newline', verbose_name='Swallow trailing newline')), 33 | ('helpline', models.CharField(max_length=120, null=True, verbose_name='Help text for this tag', blank=True)), 34 | ('display_on_editor', models.BooleanField(default=True, verbose_name='Display on editor')), 35 | ], 36 | options={ 37 | 'verbose_name': 'BBCode tag', 38 | 'verbose_name_plural': 'BBCode tags', 39 | }, 40 | bases=(models.Model,), 41 | ), 42 | migrations.CreateModel( 43 | name='SmileyTag', 44 | fields=[ 45 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 46 | ('code', precise_bbcode.fields.SmileyCodeField(unique=True, max_length=60, verbose_name='Smiley code', db_index=True)), 47 | ('image', models.ImageField(upload_to=SMILIES_UPLOAD_TO, verbose_name='Smiley icon')), 48 | ('image_width', models.PositiveIntegerField(null=True, verbose_name='Smiley icon width', blank=True)), 49 | ('image_height', models.PositiveIntegerField(null=True, verbose_name='Smiley icon height', blank=True)), 50 | ('emotion', models.CharField(max_length=100, null=True, verbose_name='Related emotion', blank=True)), 51 | ('display_on_editor', models.BooleanField(default=True, verbose_name='Display on editor')), 52 | ], 53 | options={ 54 | 'verbose_name': 'Smiley', 55 | 'verbose_name_plural': 'Smilies', 56 | }, 57 | bases=(models.Model,), 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/defaults/tag.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import URLValidator 5 | 6 | from precise_bbcode.bbcode.tag import BBCodeTag 7 | from precise_bbcode.conf import settings as bbcode_settings 8 | from precise_bbcode.core.utils import replace 9 | 10 | 11 | class StrongBBCodeTag(BBCodeTag): 12 | name = 'b' 13 | definition_string = '[b]{TEXT}[/b]' 14 | format_string = '{TEXT}' 15 | 16 | 17 | class ItalicBBCodeTag(BBCodeTag): 18 | name = 'i' 19 | definition_string = '[i]{TEXT}[/i]' 20 | format_string = '{TEXT}' 21 | 22 | 23 | class UnderlineBBCodeTag(BBCodeTag): 24 | name = 'u' 25 | definition_string = '[u]{TEXT}[/u]' 26 | format_string = '{TEXT}' 27 | 28 | 29 | class StrikeBBCodeTag(BBCodeTag): 30 | name = 's' 31 | definition_string = '[s]{TEXT}[/s]' 32 | format_string = '{TEXT}' 33 | 34 | 35 | class ListBBCodeTag(BBCodeTag): 36 | name = 'list' 37 | 38 | class Options: 39 | transform_newlines = True 40 | strip = True 41 | 42 | def render(self, value, option=None, parent=None): 43 | css_opts = { 44 | '1': 'decimal', '01': 'decimal-leading-zero', 45 | 'a': 'lower-alpha', 'A': 'upper-alpha', 46 | 'i': 'lower-roman', 'I': 'upper-roman', 47 | } 48 | list_tag = 'ol' if option in css_opts else 'ul' 49 | list_tag_css = ' style="list-style-type:{};"'.format(css_opts[option]) if list_tag == 'ol' \ 50 | else '' 51 | rendered = '<{tag}{css}>{content}'.format( 52 | tag=list_tag, css=list_tag_css, content=value) 53 | return rendered 54 | 55 | 56 | class ListItemBBCodeTag(BBCodeTag): 57 | name = '*' 58 | definition_string = '[*]{TEXT}' 59 | format_string = '
  • {TEXT}
  • ' 60 | 61 | class Options: 62 | newline_closes = True 63 | same_tag_closes = True 64 | end_tag_closes = True 65 | strip = True 66 | 67 | 68 | class QuoteBBCodeTag(BBCodeTag): 69 | name = 'quote' 70 | definition_string = '[quote]{TEXT}[/quote]' 71 | format_string = '
    {TEXT}
    ' 72 | 73 | class Options: 74 | strip = True 75 | 76 | 77 | class CodeBBCodeTag(BBCodeTag): 78 | name = 'code' 79 | definition_string = '[code]{TEXT}[/code]' 80 | format_string = '{TEXT}' 81 | 82 | class Options: 83 | render_embedded = False 84 | 85 | 86 | class CenterBBCodeTag(BBCodeTag): 87 | name = 'center' 88 | definition_string = '[center]{TEXT}[/center]' 89 | format_string = '
    {TEXT}
    ' 90 | 91 | 92 | class ColorBBCodeTag(BBCodeTag): 93 | name = 'color' 94 | definition_string = '[color={COLOR}]{TEXT}[/color]' 95 | format_string = '{TEXT}' 96 | 97 | 98 | class UrlBBCodeTag(BBCodeTag): 99 | name = 'url' 100 | 101 | _domain_re = re.compile(r'^(?=.{4,255}$)([a-zA-Z0-9][a-zA-Z0-9-]{,61}[a-zA-Z0-9]\.)+[a-zA-Z0-9]{2,5}$') # noqa 102 | 103 | class Options: 104 | replace_links = False 105 | 106 | def render(self, value, option=None, parent=None): 107 | href = option if option else value 108 | if href[0] == href[-1] and href[0] in ('"', '\'') and len(href) > 2: 109 | # URLs can be encapsulated in quotes (either single or double) that aren't part of the 110 | # URL. If that's the case, strip them out. 111 | href = href[1:-1] 112 | href = replace(href, bbcode_settings.BBCODE_ESCAPE_HTML) 113 | if '://' not in href and self._domain_re.match(href): 114 | href = 'http://' + href 115 | v = URLValidator() 116 | 117 | # Validates and renders the considered URL. 118 | try: 119 | v(href) 120 | except ValidationError: 121 | rendered = '[url={}]{}[/url]'.format(href, value) if option else \ 122 | '[url]{}[/url]'.format(value) 123 | else: 124 | content = value if option else href 125 | rendered = '{}'.format(href, content or href) 126 | 127 | return rendered 128 | 129 | 130 | class ImgBBCodeTag(BBCodeTag): 131 | name = 'img' 132 | definition_string = '[img]{URL}[/img]' 133 | format_string = '' 134 | 135 | class Options: 136 | replace_links = False 137 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | from ..conf import settings as bbcode_settings 4 | from ..core.loading import get_subclasses 5 | from .parser import BBCodeParser 6 | from .placeholder import BBCodePlaceholder 7 | from .tag import BBCodeTag 8 | 9 | 10 | _bbcode_parser = None 11 | # The easiest way to use the BBcode parser is to import the following get_parser function (except if 12 | # you need many BBCodeParser instances at a time or you want to subclass it). 13 | # Note if you create a new instance of BBCodeParser, the built in bbcode tags will not be installed. 14 | 15 | 16 | def get_parser(): 17 | if not _bbcode_parser: 18 | loader = BBCodeParserLoader() 19 | loader.load_parser() 20 | return _bbcode_parser 21 | 22 | 23 | class BBCodeParserLoader(object): 24 | def __init__(self, *args, **kwargs): 25 | parser = kwargs.pop('parser', None) 26 | if parser: 27 | self.parser = parser 28 | else: 29 | global _bbcode_parser 30 | _bbcode_parser = BBCodeParser() 31 | self.parser = _bbcode_parser 32 | 33 | def load_parser(self): 34 | # Init default BBCode placeholders 35 | self.init_default_bbcode_placeholders() 36 | 37 | # Init placeholders registered in 'bbcode_placeholders' modules 38 | self.init_bbcode_placeholders() 39 | 40 | # Init default BBCode tags 41 | if not bbcode_settings.BBCODE_DISABLE_BUILTIN_TAGS: 42 | self.init_default_bbcode_tags() 43 | 44 | # Init renderers registered in 'bbcode_tags' modules 45 | self.init_bbcode_tags() 46 | 47 | # Init custom renderers defined in BBCodeTag model instances 48 | if bbcode_settings.BBCODE_ALLOW_CUSTOM_TAGS: 49 | self.init_custom_bbcode_tags() 50 | 51 | # Init smilies 52 | if bbcode_settings.BBCODE_ALLOW_SMILIES: 53 | self.init_bbcode_smilies() 54 | 55 | def init_default_bbcode_placeholders(self): 56 | """ 57 | Find the default placeholders and makes them available for the parser. 58 | """ 59 | import precise_bbcode.bbcode.defaults.placeholder 60 | for placeholder_klass in get_subclasses( 61 | precise_bbcode.bbcode.defaults.placeholder, BBCodePlaceholder): 62 | setattr(placeholder_klass, 'default_placeholder', True) 63 | self.parser.add_placeholder(placeholder_klass) 64 | 65 | def init_bbcode_placeholders(self): 66 | """ 67 | Call the BBCode placeholder pool to fetch all the module-based placeholders 68 | and initializes them. 69 | """ 70 | from precise_bbcode.placeholder_pool import placeholder_pool 71 | placeholders = placeholder_pool.get_placeholders() 72 | for placeholder in placeholders: 73 | self.parser.add_placeholder(placeholder) 74 | 75 | def init_default_bbcode_tags(self): 76 | """ 77 | Find the default bbcode tags and makes them available for the parser. 78 | """ 79 | import precise_bbcode.bbcode.defaults.tag 80 | for tag_klass in get_subclasses( 81 | precise_bbcode.bbcode.defaults.tag, BBCodeTag): 82 | setattr(tag_klass, 'default_tag', True) 83 | self.parser.add_bbcode_tag(tag_klass) 84 | 85 | def init_bbcode_tags(self): 86 | """ 87 | Call the BBCode tag pool to fetch all the module-based tags and initializes 88 | their associated renderers. 89 | """ 90 | from precise_bbcode.tag_pool import tag_pool 91 | tags = tag_pool.get_tags() 92 | for tag_def in tags: 93 | self.parser.add_bbcode_tag(tag_def) 94 | 95 | def init_custom_bbcode_tags(self): 96 | """ 97 | Find the user-defined BBCode tags and initializes their associated renderers. 98 | """ 99 | BBCodeTag = apps.get_model('precise_bbcode', 'BBCodeTag') # noqa 100 | if BBCodeTag: 101 | custom_tags = BBCodeTag.objects.all() 102 | for tag in custom_tags: 103 | self.parser.add_bbcode_tag(tag.parser_tag_klass) 104 | 105 | def init_bbcode_smilies(self): 106 | """ 107 | Find the user-defined smilies tags and register them to the BBCode parser. 108 | """ 109 | SmileyTag = apps.get_model('precise_bbcode', 'SmileyTag') # noqa 110 | if SmileyTag: 111 | custom_smilies = SmileyTag.objects.all() 112 | for smiley in custom_smilies: 113 | self.parser.add_smiley(smiley.code, smiley.html_code) 114 | -------------------------------------------------------------------------------- /docs/basic_reference/builtin_bbcodes.rst: -------------------------------------------------------------------------------- 1 | Built-in BBCode tags 2 | ==================== 3 | 4 | *Django-precise-bbcode* comes with some built-in BBCode tags that you can use to render any content based on bbcodes. The built-in bbcodes are as follows: 5 | 6 | +------------+------------------------------+---------------------------------+-------------------------------------+ 7 | | BBCode | Function | Options | Examples | 8 | +============+==============================+=================================+=====================================+ 9 | | ``b`` | creates bold text | | [b]bold text[/b] | 10 | +------------+------------------------------+---------------------------------+-------------------------------------+ 11 | | ``i`` | creates italic text | | [i]italice text[/i] | 12 | +------------+------------------------------+---------------------------------+-------------------------------------+ 13 | | ``u`` | creates underlined text | | [u]underlined text[/u] | 14 | +------------+------------------------------+---------------------------------+-------------------------------------+ 15 | | ``s`` | creates striked text | | [s]striked text[/s] | 16 | +------------+------------------------------+---------------------------------+-------------------------------------+ 17 | | ``list`` | creates an unordered list | 1: ordered list | [list][*]one[*]two[/list] | 18 | | |  | | | 19 | | | | 01: ordered list (leading zero) | [list=1][*]one[*]two[/list] | 20 | | |  | | | 21 | | | | a: ordered list (lower alpha) | | 22 | | |  | | | 23 | | | | A: ordered list (upper alpha) | | 24 | | |  | | | 25 | | | | i: ordered list (lower roman) | | 26 | | |  | | | 27 | | | | I: ordered list (upper roman) | | 28 | +------------+------------------------------+---------------------------------+-------------------------------------+ 29 | | ``*`` | creates a list item | | [list][*]one[*]two[/list] | 30 | +------------+------------------------------+---------------------------------+-------------------------------------+ 31 | | ``code`` | retains all formatting | | [code][b]example[/b][/code] | 32 | +------------+------------------------------+---------------------------------+-------------------------------------+ 33 | | ``quote`` | creates a blockquote | | [quote]quoted string[/quote] | 34 | +------------+------------------------------+---------------------------------+-------------------------------------+ 35 | | ``center`` | creates a centered block | | [center]example[/center] | 36 | +------------+------------------------------+---------------------------------+-------------------------------------+ 37 | | ``color`` | changes the colour of a text | | [color=red]red text[/color] | 38 | | |  |  | | 39 | | |  |  | [color=#FFFFFF]white text[/color] | 40 | +------------+------------------------------+---------------------------------+-------------------------------------+ 41 | | ``url`` | creates a URL | | [url]http://example.com[/url] | 42 | | |  |  | | 43 | | |  |  | [url=http://example.com]text[/url] | 44 | +------------+------------------------------+---------------------------------+-------------------------------------+ 45 | | ``img`` | displays an image | | [img]http://xyz.com/logo.png[/img] | 46 | +------------+------------------------------+---------------------------------+-------------------------------------+ 47 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | django-precise-bbcode 3 | ===================== 4 | 5 | .. image:: https://readthedocs.org/projects/django-precise-bbcode/badge/?version=stable 6 | :target: http://django-precise-bbcode.readthedocs.org/en/stable/ 7 | :alt: Documentation Status 8 | 9 | .. image:: https://img.shields.io/pypi/l/django-precise-bbcode.svg 10 | :target: https://pypi.python.org/pypi/django-precise-bbcode/ 11 | :alt: License 12 | 13 | .. image:: http://img.shields.io/pypi/v/django-precise-bbcode.svg 14 | :target: https://pypi.python.org/pypi/django-precise-bbcode/ 15 | :alt: Latest Version 16 | 17 | .. image:: https://github.com/ellmetha/django-precise-bbcode/workflows/CI/badge.svg?branch=develop 18 | :target: https://github.com/ellmetha/django-precise-bbcode/actions 19 | :alt: Build status 20 | 21 | | 22 | *Django-precise-bbcode* is a Django application providing a way to create textual contents based on BBCodes. 23 | 24 | BBCode is a special implementation of HTML. BBCode itself is similar in style to HTML, tags are enclosed in square brackets [ and ] rather than < and > and it offers greater control over what and how something is displayed. 25 | 26 | This application includes a BBCode compiler aimed to render any BBCode content to HTML and allows the use of BBCodes tags in models, forms and admin forms. The BBCode parser comes with built-in tags (the default ones ; ``b``, ``u``, etc) and allows the use of smilies, custom BBCode placeholders and custom BBCode tags. These can be added in two different ways: 27 | 28 | * Custom tags can be defined in the Django administration panel and stored into the database ; doing this allows any non-technical admin to add BBCode tags by defining the HTML replacement string associated with each tag 29 | * Tags can also be manually registered to be used by the parser by defining a tag class aimed to render a given bbcode tag and its content to the corresponding HTML markup 30 | 31 | .. contents:: Table of Contents 32 | :local: 33 | 34 | 35 | Documentation 36 | ------------- 37 | 38 | Online browsable documentation is available at https://django-precise-bbcode.readthedocs.org. 39 | 40 | 41 | Requirements 42 | ------------ 43 | 44 | * Python 3.6+ 45 | * Django 3.2+ 46 | * PIL or Pillow (required for smiley tags) 47 | 48 | Installation 49 | ------------ 50 | 51 | Just run: 52 | 53 | :: 54 | 55 | pip install django-precise-bbcode 56 | 57 | Once installed you can configure your project to use *django-precise-bbcode* with the following steps. 58 | 59 | Add ``precise_bbcode`` to ``INSTALLED_APPS`` in your project's settings module: 60 | 61 | .. code-block:: python 62 | 63 | INSTALLED_APPS = ( 64 | # other apps 65 | 'precise_bbcode', 66 | ) 67 | 68 | Then install the models: 69 | 70 | .. code-block:: shell 71 | 72 | python manage.py migrate 73 | 74 | Usage 75 | ----- 76 | 77 | Rendering bbcodes 78 | ***************** 79 | 80 | *Django-precise-bbcode* comes with a BBCode parser that allows you to transform a textual content containing BBCode tags to the corresponding HTML markup. To do this, simply import the ``get_parser`` shortcut and use the ``render`` method of the BBCode parser:: 81 | 82 | >>> from precise_bbcode.bbcode import get_parser 83 | >>> parser = get_parser() 84 | >>> parser.render('[b]Hello [u]world![/u][/b]') 85 | 'Hello world!' 86 | 87 | *It's that easy!* 88 | 89 | As you may need to render bbcodes inside one of your Django template, this parser can be used as a template filter or as a template tag after loading ``bbcode_tags``:: 90 | 91 | {% load bbcode_tags %} 92 | {% bbcode entry.bbcode_content %} 93 | {{ "[b]Write some bbcodes![/b]"|bbcode }} 94 | 95 | The BBCode content included in the ``entry.bbcode_content`` field will be converted to HTML and displayed. The last statement will output ``Write some bbcodes!``. 96 | 97 | Storing bbcodes 98 | *************** 99 | 100 | While you can use the Django built-in ``models.TextField`` to add your BBCode contents to your models, a common need is to store both the BBCode content and the corresponding HTML markup in the database. To address this *django-precise-bbcode* provides a ``BBCodeTextField``. 101 | 102 | .. code-block:: python 103 | 104 | from django.db import models 105 | from precise_bbcode.fields import BBCodeTextField 106 | 107 | class Post(models.Model): 108 | content = BBCodeTextField() 109 | 110 | This field will store both the BBCode content and the correspondign HTML markup. The HTML content of such a field can then be displayed in any template by using its ``rendered`` attribute: 111 | 112 | :: 113 | 114 | {{ post.content.rendered }} 115 | 116 | And more... 117 | *********** 118 | 119 | Head over to the `documentation `_ for all the details on how to use the BBCode parser and how to define custom BBcode tags, placeholders and smilies. 120 | 121 | Authors 122 | ------- 123 | 124 | Morgan Aubert (`@ellmetha `_) and contributors_ 125 | 126 | .. _contributors: https://github.com/ellmetha/django-precise-bbcode/contributors 127 | 128 | License 129 | ------- 130 | 131 | BSD. See ``LICENSE`` for more details. 132 | -------------------------------------------------------------------------------- /example_project/example_project/settings/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | PROJECT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../..') 4 | 5 | DEBUG = False 6 | TEMPLATE_DEBUG = DEBUG 7 | 8 | ALLOWED_HOSTS = [] 9 | 10 | # Local time zone for this installation. Choices can be found here: 11 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 12 | # although not all choices may be available on all operating systems. 13 | # In a Windows environment this must be set to your system time zone. 14 | TIME_ZONE = 'Europe/Paris' 15 | 16 | # Language code for this installation. All choices can be found here: 17 | # http://www.i18nguy.com/unicode/language-identifiers.html 18 | LANGUAGE_CODE = 'fr' 19 | 20 | LANGUAGES = ( 21 | ('en', "English"), 22 | ('fr', "Français"), 23 | ) 24 | 25 | DATABASES = { 26 | 'default': { 27 | 'ENGINE': 'django.db.backends.sqlite3', 28 | 'NAME': os.path.join(PROJECT_PATH, 'example.db'), 29 | } 30 | } 31 | 32 | LOCALE_PATHS = ( 33 | os.path.join(PROJECT_PATH, 'src/locale/example_project'), 34 | ) 35 | 36 | SITE_ID = 1 37 | 38 | # If you set this to False, Django will make some optimizations so as not 39 | # to load the internationalization machinery. 40 | USE_I18N = True 41 | 42 | # If you set this to False, Django will not format dates, numbers and 43 | # calendars according to the current locale. 44 | USE_L10N = True 45 | 46 | # If you set this to False, Django will not use timezone-aware datetimes. 47 | USE_TZ = True 48 | 49 | # URL of the admin page 50 | ADMIN_URL = 'admin/' 51 | 52 | # Absolute filesystem path to the directory that will hold user-uploaded files. 53 | # Example: "/home/media/media.lawrence.com/media/" 54 | MEDIA_ROOT = os.path.join(PROJECT_PATH, 'public/media/') 55 | 56 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 57 | # trailing slash. 58 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 59 | MEDIA_URL = '/media/' 60 | 61 | # Absolute path to the directory static files should be collected to. 62 | # Don't put anything in this directory yourself; store your static files 63 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 64 | # Example: "/home/media/media.lawrence.com/static/" 65 | STATIC_ROOT = os.path.join(PROJECT_PATH, 'public/static/') 66 | 67 | # URL prefix for static files. 68 | # Example: "http://media.lawrence.com/static/" 69 | STATIC_URL = '/static/' 70 | 71 | # Additional locations of static files 72 | STATICFILES_DIRS = () 73 | 74 | TEMPLATES = [ 75 | { 76 | 'BACKEND': 'django.template.backends.jinja2.Jinja2', 77 | 'APP_DIRS': True, 78 | 'DIRS': [ 79 | os.path.join(PROJECT_PATH, 'src/example_project/jinja2/'), 80 | ], 81 | 'OPTIONS': { 82 | 'extensions': [ 83 | 'precise_bbcode.jinja2tags.bbcode', 84 | ], 85 | }, 86 | }, 87 | { 88 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 89 | 'OPTIONS': { 90 | 'context_processors': [ 91 | 'django.contrib.auth.context_processors.auth', 92 | 'django.template.context_processors.debug', 93 | 'django.template.context_processors.i18n', 94 | 'django.template.context_processors.media', 95 | 'django.template.context_processors.static', 96 | 'django.contrib.messages.context_processors.messages', 97 | 'django.template.context_processors.request', 98 | ], 99 | 'loaders': [ 100 | ('django.template.loaders.cached.Loader', ( 101 | 'django.template.loaders.filesystem.Loader', 102 | 'django.template.loaders.app_directories.Loader', 103 | )), 104 | ] 105 | }, 106 | }, 107 | ] 108 | 109 | # List of finder classes that know how to find static files in 110 | # various locations. 111 | STATICFILES_FINDERS = ( 112 | 'django.contrib.staticfiles.finders.FileSystemFinder', 113 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 114 | ) 115 | 116 | # Make this unique, and don't share it with anybody. 117 | SECRET_KEY = '833090dhkgrfgdfg#ddfggdfdf*fds5645456fg' 118 | 119 | MIDDLEWARE = ( 120 | 'django.middleware.common.CommonMiddleware', 121 | 'django.contrib.sessions.middleware.SessionMiddleware', 122 | 'django.middleware.csrf.CsrfViewMiddleware', 123 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 124 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 125 | 'django.contrib.messages.middleware.MessageMiddleware', 126 | 'django.middleware.gzip.GZipMiddleware', 127 | ) 128 | 129 | ROOT_URLCONF = 'example_project.urls' 130 | 131 | # Python dotted path to the WSGI application used by Django's runserver. 132 | WSGI_APPLICATION = 'example_project.wsgi.application' 133 | 134 | INSTALLED_APPS = ( 135 | # Django apps 136 | 'django.contrib.auth', 137 | 'django.contrib.contenttypes', 138 | 'django.contrib.sessions', 139 | 'django.contrib.sites', 140 | 'django.contrib.sitemaps', 141 | 'django.contrib.messages', 142 | 'django.contrib.staticfiles', 143 | 'django.contrib.admin', 144 | 145 | # Precise BBCode app 146 | 'precise_bbcode', 147 | 148 | # Test apps 149 | 'example_project', 150 | 'test_messages', 151 | ) 152 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-precise-bbcode.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-precise-bbcode.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-precise-bbcode" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-precise-bbcode" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /precise_bbcode/fields.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.validators import RegexValidator 4 | from django.db import models 5 | from django.db.models import signals 6 | from django.utils.safestring import mark_safe 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from .bbcode import get_parser 10 | 11 | 12 | __all__ = ( 13 | 'BBCodeContent', 14 | 'BBCodeTextField', 15 | 'SmileyCodeField', 16 | ) 17 | 18 | 19 | _rendered_content_field_name = lambda name: '_{}_rendered'.format(name) 20 | 21 | _smiley_code_re = re.compile(r'^[\w|\S]+$') 22 | validate_smiley_code = RegexValidator( 23 | _smiley_code_re, 24 | _("Enter a valid 'smiley code' consisting of any character without whitespace characters"), 25 | 'invalid') 26 | 27 | 28 | class BBCodeContent(object): 29 | def __init__(self, raw, rendered=None): 30 | self.raw = raw 31 | self.rendered = mark_safe(rendered) if rendered else rendered 32 | 33 | def __str__(self): 34 | return self.raw 35 | 36 | 37 | class BBCodeTextCreator(object): 38 | """ 39 | Acts as the Django's default attribute descriptor class (enabled via the SubfieldBase 40 | metaclass). The main difference is that it does not call to_python() on the BBCodeTextField 41 | class. Instead, it stores the two different values of a BBCode content (the raw and the rendered 42 | data) separately. These values can be separately updated when something is assigned. When the 43 | field is accessed, a BBCodeContent instance will be returned ; this one is built with the 44 | current data. 45 | """ 46 | def __init__(self, field): 47 | self.field = field 48 | self.rendered_field_name = _rendered_content_field_name(self.field.name) 49 | 50 | def __get__(self, instance, type=None): 51 | if instance is None: 52 | return self.field 53 | raw_content = instance.__dict__[self.field.name] 54 | if raw_content is None: 55 | return None 56 | else: 57 | return BBCodeContent(raw_content, rendered=getattr(instance, self.rendered_field_name)) 58 | 59 | def __set__(self, instance, value): 60 | if isinstance(value, BBCodeContent): 61 | instance.__dict__[self.field.name] = value.raw 62 | setattr(instance, self.rendered_field_name, value.rendered) 63 | else: 64 | # Set only the bbcode content field 65 | instance.__dict__[self.field.name] = self.field.to_python(value) 66 | 67 | 68 | class BBCodeTextField(models.TextField): 69 | """ 70 | A BBCode text field contributes two columns to the model instead of the standard single column. 71 | The initial column stores the BBCode content and the other one keeps the rendered content 72 | returned by the BBCode parser. 73 | """ 74 | def __init__(self, *args, **kwargs): 75 | # For Django 1.7 migration serializer compatibility: the frozen version of a 76 | # BBCodeTextField can't try to add a '*_rendered' field, because the '*_rendered' field 77 | # itself is frozen / serialized as well. 78 | self.add_rendered_field = not kwargs.pop('no_rendered_field', False) 79 | super(BBCodeTextField, self).__init__(*args, **kwargs) 80 | 81 | def deconstruct(self): 82 | """ 83 | As outlined in the Django 1.7 documentation, this method tells Django how to take an 84 | instance of a new field in order to reduce it to a serialized form. This can be used to 85 | configure what arguments need to be passed to the __init__() method of the field in order to 86 | re-create it. We use it in order to pass the 'no_rendered_field' to the __init__() method. 87 | This will allow the _rendered field to not be added to the model class twice. 88 | """ 89 | name, import_path, args, kwargs = super(BBCodeTextField, self).deconstruct() 90 | kwargs['no_rendered_field'] = True 91 | return name, import_path, args, kwargs 92 | 93 | def contribute_to_class(self, cls, name): 94 | self.raw_name = name 95 | 96 | if self.add_rendered_field and not cls._meta.abstract: 97 | self.rendered_field_name = _rendered_content_field_name(name) 98 | 99 | # Create a hidden 'rendered' field 100 | rendered = models.TextField(editable=False, null=True, blank=True) 101 | # Ensure that the 'rendered' field appears before the actual field in 102 | # the models _meta.fields 103 | rendered.creation_counter = self.creation_counter 104 | cls.add_to_class(self.rendered_field_name, rendered) 105 | 106 | # The data will be processed before each save 107 | signals.pre_save.connect(self.process_bbcodes, sender=cls) 108 | 109 | # Add the default text field 110 | super(BBCodeTextField, self).contribute_to_class(cls, name) 111 | 112 | # Associates the name of this field to a special descriptor that will return 113 | # an appropriate BBCodeContent object each time the field is accessed 114 | self.set_descriptor_class(cls) 115 | 116 | def set_descriptor_class(self, cls): 117 | setattr(cls, self.name, BBCodeTextCreator(self)) 118 | 119 | def get_db_prep_save(self, value, connection): 120 | if isinstance(value, BBCodeContent): 121 | value = value.raw 122 | return super(BBCodeTextField, self).get_db_prep_save(value, connection) 123 | 124 | def process_bbcodes(self, signal, sender, instance=None, **kwargs): 125 | bbcode_text = getattr(instance, self.raw_name) 126 | 127 | if isinstance(bbcode_text, BBCodeContent): 128 | bbcode_text = bbcode_text.raw 129 | 130 | rendered = '' 131 | if bbcode_text: 132 | parser = get_parser() 133 | rendered = parser.render(bbcode_text) 134 | 135 | setattr(instance, self.rendered_field_name, rendered) 136 | 137 | 138 | class SmileyCodeField(models.CharField): 139 | default_validators = [validate_smiley_code] 140 | description = _("Smiley code (up to %(max_length)s)") 141 | 142 | def __init__(self, *args, **kwargs): 143 | kwargs['max_length'] = kwargs.get('max_length', 50) 144 | # Set db_index=True unless it's been set manually. 145 | if 'db_index' not in kwargs: 146 | kwargs['db_index'] = True 147 | super(SmileyCodeField, self).__init__(*args, **kwargs) 148 | -------------------------------------------------------------------------------- /precise_bbcode/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Morgan Aubert , 2013. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-12-13 00:51+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Morgan Aubert \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: admin.py:20 22 | msgid "Advanced options" 23 | msgstr "Options avancées" 24 | 25 | #: fields.py:20 26 | msgid "" 27 | "Enter a valid 'smiley code' consisting of any character without whitespace " 28 | "characters" 29 | msgstr "" 30 | "Entrez un code smiley valide ne comprenant aucun " 31 | "espace" 32 | 33 | #: fields.py:125 34 | #, python-format 35 | msgid "Smiley code (up to %(max_length)s)" 36 | msgstr "Code smiley (jusqu'à %(max_length)s caractères)" 37 | 38 | #: models.py:24 39 | msgid "BBCode tag name" 40 | msgstr "Nom du BBCode" 41 | 42 | #: models.py:25 43 | msgid "Tag definition" 44 | msgstr "Définition du BBCode" 45 | 46 | #: models.py:26 47 | msgid "Replacement HTML code" 48 | msgstr "Code HTML de remplacement" 49 | 50 | #: models.py:30 51 | msgid "Newline closing" 52 | msgstr "Nouvelle ligne fermante" 53 | 54 | #: models.py:31 55 | msgid "Set this option to force the closing of this tag after a newline" 56 | msgstr "" 57 | "Activer cette option pour forcer la fermeture du bbcode après un saut de " 58 | "ligne" 59 | 60 | #: models.py:34 61 | msgid "Same tag closing" 62 | msgstr "Fermeture du bbcode si duplicata" 63 | 64 | #: models.py:35 65 | msgid "" 66 | "Set this option to force the closing of this tag after the beginning of a " 67 | "similar tag" 68 | msgstr "" 69 | "Activer cette option pour forcer la fermeture du bbcode si un bbcode " 70 | "similaire est rencontré" 71 | 72 | #: models.py:38 73 | msgid "End tag closing" 74 | msgstr "Fin de tag si balise de fermeture rencontrée" 75 | 76 | #: models.py:39 77 | msgid "" 78 | "Set this option to force the closing of this tag after the end of another tag" 79 | msgstr "" 80 | "Activer cette option pour forcer la fermeture du bbcode si une balise de fin " 81 | "de tag quelconque est rencontrée" 82 | 83 | #: models.py:42 84 | msgid "Standalone tag" 85 | msgstr "Tag sans contexte" 86 | 87 | #: models.py:43 88 | msgid "Set this option if this tag does not have a closing tag" 89 | msgstr "" 90 | "Activer cette option si le bbcode ne doit pas avoir de balise de fermeture" 91 | 92 | #: models.py:46 93 | msgid "Transform line breaks" 94 | msgstr "Transformer les sauts de ligne" 95 | 96 | #: models.py:47 97 | msgid "Set this option to convert any line break to the equivalent markup" 98 | msgstr "" 99 | "Activer cette option afin de convertir tout saut de ligne en notation HTML" 100 | 101 | #: models.py:50 102 | msgid "Render embedded tags" 103 | msgstr "Convertir les bbcodes imbriqués" 104 | 105 | #: models.py:51 106 | msgid "Set this option to force the tags embedded in this tag to be rendered" 107 | msgstr "" 108 | "Activer cette option pour forcer la converion des bbcodes imbriqués en leurs " 109 | "équivalents HTML" 110 | 111 | #: models.py:54 112 | msgid "Escape HTML characters" 113 | msgstr "Echapper les caractères HTML" 114 | 115 | #: models.py:55 116 | msgid "Set this option to escape HTML characters (<, >, and &) inside this tag" 117 | msgstr "" 118 | "Activer cette option afin d'échapper les caractères HTML (<, > a &) à " 119 | "l'intérieur de ce bbcode" 120 | 121 | #: models.py:58 122 | msgid "Replace links" 123 | msgstr "Remplacer les liens" 124 | 125 | #: models.py:59 126 | msgid "Set this option to replace URLs with link markups inside this tag" 127 | msgstr "" 128 | "Activer cette option afin de remplacer les URLs par les balises HTML " 129 | "correspondantes à l'intérieur de cet bbcode" 130 | 131 | #: models.py:62 132 | msgid "Strip leading and trailing whitespace" 133 | msgstr "Supprimer les espaces de début et de fin du contenu du bbcode" 134 | 135 | #: models.py:63 136 | msgid "" 137 | "Set this option to strip leading and trailing whitespace inside this tag" 138 | msgstr "" 139 | "Activer cette option pour supprimer les espaces de début et de fin du " 140 | "contenu du bbcode" 141 | 142 | #: models.py:66 143 | msgid "Swallow trailing newline" 144 | msgstr "Supprimer les sauts de ligne" 145 | 146 | #: models.py:67 147 | msgid "Set this option to swallow the first trailing newline" 148 | msgstr "" 149 | "Activer cette option pour supprimer les sauts de ligne du contenu du bbcode" 150 | 151 | #: models.py:71 152 | msgid "Help text for this tag" 153 | msgstr "Texte d'aide pour ce bbcode" 154 | 155 | #: models.py:72 models.py:160 156 | msgid "Display on editor" 157 | msgstr "Afficher sur l'éditeur" 158 | 159 | #: models.py:75 160 | msgid "BBCode tag" 161 | msgstr "BBCode" 162 | 163 | #: models.py:76 164 | msgid "BBCode tags" 165 | msgstr "BBCodes" 166 | 167 | #: models.py:90 168 | msgid "The BBCode definition you provided is not valid" 169 | msgstr "La définition du bbcode n'est pas valide" 170 | 171 | #: models.py:95 172 | msgid "" 173 | "This BBCode tag dit not validate because the start tag and the tag names are " 174 | "not the same" 175 | msgstr "" 176 | "Ce bbcode est invalide car les balises ouvrante et fermantes ne sont pas les " 177 | "mêmes" 178 | 179 | #: models.py:98 180 | msgid "A BBCode tag with this name appears to already exist" 181 | msgstr "Un bbcode disposant de ce nom existe déja" 182 | 183 | #: models.py:104 184 | msgid "" 185 | "The placeholders defined in the tag definition must be present in the HTML " 186 | "replacement code!" 187 | msgstr "" 188 | "Les zones de contenu définies dans le bbcode doivent être présentes dans le " 189 | "code HTML de remplacement!" 190 | 191 | #: models.py:109 192 | msgid "The placeholders defined in the tag definition must be strictly uniques" 193 | msgstr "" 194 | "Les zones de contenu définies dans le bbcode doivent être strictement uniques" 195 | 196 | #: models.py:116 197 | msgid "You can only use placeholder names among: " 198 | msgstr "Seuls les types de zones de contenu suivants peuvent être utilisés : " 199 | 200 | #: models.py:153 201 | msgid "Smiley code" 202 | msgstr "Code smiley" 203 | 204 | #: models.py:154 205 | msgid "Smiley icon" 206 | msgstr "Icône du smiley" 207 | 208 | #: models.py:155 209 | msgid "Smiley icon width" 210 | msgstr "Largeur de l'icône" 211 | 212 | #: models.py:156 213 | msgid "Smiley icon height" 214 | msgstr "Hauteur de l'icône" 215 | 216 | #: models.py:159 217 | msgid "Related emotion" 218 | msgstr "Émotion associée" 219 | 220 | #: models.py:163 221 | msgid "Smiley" 222 | msgstr "Smiley" 223 | 224 | #: models.py:164 225 | msgid "Smilies" 226 | msgstr "Smileys" 227 | -------------------------------------------------------------------------------- /docs/extending_precise_bbcode/custom_placeholders.rst: -------------------------------------------------------------------------------- 1 | ########################## 2 | Custom BBCode placeholders 3 | ########################## 4 | 5 | When you define bbcode tags, you can choose to use placeholders in order to define where data is required. These placeholders, such as ``{TEXT}`` or ``{NUMBER}`` are typed. This means that some semantic verifications are done before rendering in order to ensure that the content corresponding to a specific placeholder is valid. *Django-precise-bbcode* comes with some built-in BBCode placeholders that you can use in your bbcode tag definitions. You can also choose to create your owns. 6 | 7 | Built-in placeholders 8 | --------------------- 9 | 10 | +-----------------+---------------------+--------------------------------------------------+ 11 | | Placeholder | Usage | Definition | 12 | +=================+=====================+==================================================+ 13 | | ``{TEXT}`` |  | matches anything | 14 | +-----------------+---------------------+--------------------------------------------------+ 15 | | ``{SIMPLETEXT}``|  | matches latin characters, numbers, spaces, | 16 | | | | | 17 | | | | commas, dots, minus, plus, hyphen and underscore | 18 | +-----------------+---------------------+--------------------------------------------------+ 19 | | ``{COLOR}`` |  | matches a colour (eg. ``red`` or ``#000FFF``) | 20 | +-----------------+---------------------+--------------------------------------------------+ 21 | | ``{NUMBER}`` | | matches a number | 22 | +-----------------+---------------------+--------------------------------------------------+ 23 | | ``{URL}`` | | matches a valid URL | 24 | +-----------------+---------------------+--------------------------------------------------+ 25 | | ``{EMAIL}`` |  | matches a valid e-mail address | 26 | +-----------------+---------------------+--------------------------------------------------+ 27 | | ``{RANGE}`` | ``{RANGE=min,max}`` | matches a valid number between *min* and *max* | 28 | +-----------------+---------------------+--------------------------------------------------+ 29 | | ``{CHOICE}`` | ``{CHOICE=foo,bar}``| matches all strings separated with commas in the | 30 | | |  | | 31 | | |  | placeholder | 32 | +-----------------+---------------------+--------------------------------------------------+ 33 | 34 | Defining BBCode placeholders plugins 35 | ------------------------------------ 36 | 37 | *Django-precise-bbcode* allows you to define your own placeholders. 38 | 39 | To do so, you will have to write a subclass of ``precise_bbcode.bbcode.placeholder.BBCodePlaceholder`` for any placeholder you want to create. These class-based bbcode placeholders must be defined inside a ``bbcode_placeholders`` Python module in your Django application (just add a file called ``bbcode_placeholders.py`` to an existing Django application). In the same way as bbcode tag classes, your class-based bbcode placeholders must be registered to the ``precise_bbcode.placeholder_pool.placeholder_pool`` object by using its ``register_placeholder`` method to be available to the BBCode parser. 40 | 41 | Each bbcode placeholder must have a ``name`` attribute and can operate in two different ways: 42 | 43 | * The ``BBCodePlaceholder`` subclass provides a ``pattern`` attribute, which is a valid regular expression. In this case, a given content will be valid in the context of the placeholder if it matches this regular expression 44 | * The ``BBCodePlaceholder`` subclass implements a ``validate`` method. This method is used to check whether a given content is valid according to the placeholder definition associated to it and should return ``True`` or ``False`` 45 | 46 | Defining placeholders based on a regular expression pattern 47 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | In this case, you have to provide a ``pattern`` attribute to your ``BBCodePlaceholder`` subclass, in addition to the ``name`` attribute. 50 | 51 | Let's write a simple example. Consider we are trying to write a ``{PHONENUMBER}`` bbcode placeholder which will allow end-users to fill some bbcode tags with phone numbers. So we could write:: 52 | 53 | # bbcode_placeholders.py 54 | import re 55 | from precise_bbcode.bbcode.placeholder import BBCodePlaceholder 56 | from precise_bbcode.placeholder_pool import placeholder_pool 57 | 58 | class PhoneNumberBBCodePlaceholder(BBCodePlaceholder): 59 | name = 'phonenumber' 60 | pattern = re.compile(r'(\d{3}[-\.\s]??\d{3}[-\.\s]??\d{4}|\(\d{3}\)\s*\d{3}[-\.\s]??\d{4}|\d{3}[-\.\s]??\d{4})') 61 | 62 | placeholder_pool.register_placeholder(PhoneNumberBBCodePlaceholder) 63 | 64 | So if this placeholder is used inside, let's say, a ``[telto]`` bbcode tag, ``+33000000000`` will be a valid input. 65 | 66 | Defining placeholders based on a ``validate`` method 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | In this case, each of your ``BBCodePlaceholder`` subclasses must provide a ``name`` attribute and must implement a ``validate`` method. This method is used to check whether the input associated with a placeholder is valid and can be rendered. The ``validate`` method takes two arguments: 70 | 71 | * **content**: the content used to fill the placeholder that must be validated 72 | * **extra_context**: the extra context of the placeholder if defined in a tag definition 73 | 74 | Note that the extra context is a string defined in the placeholder in a bbcode tag definition. For example, ``CHOICE`` is the placeholder name and ``apple,tomato`` is the extra context if the ``CHOICE`` placeholder is used as follows inside a bbcode tag definition: ``{CHOICE=apple,tomato}``. 75 | 76 | Let's write an example. Consider we are trying to write a ``{RANGE}`` placeholder which will allow end-users to fill some bbcode tags with a number that will be valid only if it is within a specific range. So we could write:: 77 | 78 | # bbcode_placeholders.py 79 | import re 80 | from precise_bbcode.bbcode.placeholder import BBCodePlaceholder 81 | from precise_bbcode.placeholder_pool import placeholder_pool 82 | 83 | class RangeBBCodePlaceholder(BBCodePlaceholder): 84 | name = 'range' 85 | 86 | def validate(self, content, extra_context): 87 | try: 88 | value = float(content) 89 | except ValueError: 90 | return False 91 | 92 | try: 93 | min_content, max_content = extra_context.split(',') 94 | min_value, max_value = float(min_content), float(max_content) 95 | except ValueError: 96 | return False 97 | 98 | if not (value >= min_value and value <= max_value): 99 | return False 100 | 101 | return True 102 | 103 | 104 | placeholder_pool.register_placeholder(RangeBBCodePlaceholder) 105 | 106 | The ``validate`` method allows you to implement your own validation logic for your custom placeholders. 107 | 108 | Overriding default BBCode placeholders 109 | -------------------------------------- 110 | 111 | When loaded, the parser provided by *django-precise-bbcode* provides some default bbcode placeholders (please refer to `Built-in placeholders`_ for the full list of default placeholders). These default placeholders can be overridden. You just have to register another placeholder with the same name and it will override the default one. -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-precise-bbcode documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Dec 1 13:24:04 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | ON_RTD = os.environ.get('READTHEDOCS', None) == 'True' 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = [] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'django-precise-bbcode' 46 | copyright = u'2013-2017, Morgan Aubert' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | 53 | # The short X.Y version. 54 | version = '1.2.17.dev' 55 | # The full version, including alpha/beta/rc tags. 56 | release = '1.2.17.dev' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'default' 98 | 99 | if not ON_RTD: # only import and set the theme if we're building docs locally 100 | import sphinx_rtd_theme 101 | html_theme = 'sphinx_rtd_theme' 102 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 103 | 104 | # Theme options are theme-specific and customize the look and feel of a theme 105 | # further. For a list of options available for each theme, see the 106 | # documentation. 107 | #html_theme_options = {} 108 | 109 | # Add any paths that contain custom themes here, relative to this directory. 110 | #html_theme_path = [] 111 | 112 | # The name for this set of Sphinx documents. If None, it defaults to 113 | # " v documentation". 114 | #html_title = None 115 | 116 | # A shorter title for the navigation bar. Default is the same as html_title. 117 | #html_short_title = None 118 | 119 | # The name of an image file (relative to this directory) to place at the top 120 | # of the sidebar. 121 | #html_logo = None 122 | 123 | # The name of an image file (within the static path) to use as favicon of the 124 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 125 | # pixels large. 126 | #html_favicon = None 127 | 128 | # Add any paths that contain custom static files (such as style sheets) here, 129 | # relative to this directory. They are copied after the builtin static files, 130 | # so a file named "default.css" will overwrite the builtin "default.css". 131 | html_static_path = ['_static'] 132 | 133 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 134 | # using the given strftime format. 135 | #html_last_updated_fmt = '%b %d, %Y' 136 | 137 | # If true, SmartyPants will be used to convert quotes and dashes to 138 | # typographically correct entities. 139 | #html_use_smartypants = True 140 | 141 | # Custom sidebar templates, maps document names to template names. 142 | #html_sidebars = {} 143 | 144 | # Additional templates that should be rendered to pages, maps page names to 145 | # template names. 146 | #html_additional_pages = {} 147 | 148 | # If false, no module index is generated. 149 | #html_domain_indices = True 150 | 151 | # If false, no index is generated. 152 | #html_use_index = True 153 | 154 | # If true, the index is split into individual pages for each letter. 155 | #html_split_index = False 156 | 157 | # If true, links to the reST sources are added to the pages. 158 | #html_show_sourcelink = True 159 | 160 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 161 | #html_show_sphinx = True 162 | 163 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 164 | #html_show_copyright = True 165 | 166 | # If true, an OpenSearch description file will be output, and all pages will 167 | # contain a tag referring to it. The value of this option must be the 168 | # base URL from which the finished HTML is served. 169 | #html_use_opensearch = '' 170 | 171 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 172 | #html_file_suffix = None 173 | 174 | # Output file base name for HTML help builder. 175 | htmlhelp_basename = 'django-precise-bbcodedoc' 176 | 177 | 178 | # -- Options for LaTeX output -------------------------------------------------- 179 | 180 | latex_elements = { 181 | # The paper size ('letterpaper' or 'a4paper'). 182 | #'papersize': 'letterpaper', 183 | 184 | # The font size ('10pt', '11pt' or '12pt'). 185 | #'pointsize': '10pt', 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #'preamble': '', 189 | } 190 | 191 | # Grouping the document tree into LaTeX files. List of tuples 192 | # (source start file, target name, title, author, documentclass [howto/manual]). 193 | latex_documents = [ 194 | ('index', 'django-precise-bbcode.tex', u'django-precise-bbcode Documentation', 195 | u'Morgan Aubert', 'manual'), 196 | ] 197 | 198 | # The name of an image file (relative to this directory) to place at the top of 199 | # the title page. 200 | #latex_logo = None 201 | 202 | # For "manual" documents, if this is true, then toplevel headings are parts, 203 | # not chapters. 204 | #latex_use_parts = False 205 | 206 | # If true, show page references after internal links. 207 | #latex_show_pagerefs = False 208 | 209 | # If true, show URL addresses after external links. 210 | #latex_show_urls = False 211 | 212 | # Documents to append as an appendix to all manuals. 213 | #latex_appendices = [] 214 | 215 | # If false, no module index is generated. 216 | #latex_domain_indices = True 217 | 218 | 219 | # -- Options for manual page output -------------------------------------------- 220 | 221 | # One entry per manual page. List of tuples 222 | # (source start file, name, description, authors, manual section). 223 | man_pages = [ 224 | ('index', 'django-precise-bbcode', u'django-precise-bbcode Documentation', 225 | [u'Morgan Aubert'], 1) 226 | ] 227 | 228 | # If true, show URL addresses after external links. 229 | #man_show_urls = False 230 | 231 | 232 | # -- Options for Texinfo output ------------------------------------------------ 233 | 234 | # Grouping the document tree into Texinfo files. List of tuples 235 | # (source start file, target name, title, author, 236 | # dir menu entry, description, category) 237 | texinfo_documents = [ 238 | ('index', 'django-precise-bbcode', u'django-precise-bbcode Documentation', 239 | u'Morgan Aubert', 'django-precise-bbcode', 'A django BBCode integration..', 240 | 'Miscellaneous'), 241 | ] 242 | 243 | # Documents to append as an appendix to all manuals. 244 | #texinfo_appendices = [] 245 | 246 | # If false, no module index is generated. 247 | #texinfo_domain_indices = True 248 | 249 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 250 | #texinfo_show_urls = 'footnote' 251 | -------------------------------------------------------------------------------- /precise_bbcode/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | from django.utils.encoding import force_str 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .bbcode import get_parser 9 | from .bbcode.regexes import bbcodde_standalone_re 10 | from .bbcode.regexes import bbcodde_standard_re 11 | from .bbcode.regexes import placeholder_content_re 12 | from .bbcode.regexes import placeholder_re 13 | from .bbcode.tag import BBCodeTag as ParserBBCodeTag 14 | from .bbcode.tag import BBCodeTagOptions 15 | from .conf import settings as bbcode_settings 16 | from .fields import SmileyCodeField 17 | 18 | 19 | class BBCodeTag(models.Model): 20 | tag_name = models.SlugField(max_length=20, verbose_name=_('BBCode tag name'), unique=True) 21 | tag_definition = models.TextField(verbose_name=_('Tag definition')) 22 | html_replacement = models.TextField(verbose_name=_('Replacement HTML code')) 23 | 24 | # Tag options 25 | newline_closes = models.BooleanField( 26 | verbose_name=_('Newline closing'), 27 | help_text=_('Set this option to force the closing of this tag after a newline'), 28 | default=False) 29 | same_tag_closes = models.BooleanField( 30 | verbose_name=_('Same tag closing'), 31 | help_text=_('Set this option to force the closing of this tag after the ' 32 | 'beginning of a similar tag'), 33 | default=False) 34 | end_tag_closes = models.BooleanField( 35 | verbose_name=_('End tag closing'), 36 | help_text=_('Set this option to force the closing of this tag after the end ' 37 | 'of another tag'), 38 | default=False) 39 | standalone = models.BooleanField( 40 | verbose_name=_('Standalone tag'), 41 | help_text=_('Set this option if this tag does not have a closing tag'), 42 | default=False) 43 | transform_newlines = models.BooleanField( 44 | verbose_name=_('Transform line breaks'), 45 | help_text=_('Set this option to convert any line break to the equivalent markup'), 46 | default=True) 47 | render_embedded = models.BooleanField( 48 | verbose_name=_('Render embedded tags'), 49 | help_text=_('Set this option to force the tags embedded in this tag to be rendered'), 50 | default=True) 51 | escape_html = models.BooleanField( 52 | verbose_name=_('Escape HTML characters'), 53 | help_text=_('Set this option to escape HTML characters (<, >, and &) inside this tag'), 54 | default=True) 55 | replace_links = models.BooleanField( 56 | verbose_name=_('Replace links'), 57 | help_text=_('Set this option to replace URLs with link markups inside this tag'), 58 | default=True) 59 | strip = models.BooleanField( 60 | verbose_name=_('Strip leading and trailing whitespace'), 61 | help_text=_('Set this option to strip leading and trailing whitespace inside this tag'), 62 | default=False) 63 | swallow_trailing_newline = models.BooleanField( 64 | verbose_name=_('Swallow trailing newline'), 65 | help_text=_('Set this option to swallow the first trailing newline'), 66 | default=False) 67 | 68 | # For later use 69 | helpline = models.CharField( 70 | max_length=120, verbose_name=_('Help text for this tag'), null=True, blank=True) 71 | display_on_editor = models.BooleanField(verbose_name=_('Display on editor'), default=True) 72 | 73 | class Meta: 74 | verbose_name = _('BBCode tag') 75 | verbose_name_plural = _('BBCode tags') 76 | app_label = 'precise_bbcode' 77 | 78 | def __str__(self): 79 | return '{}'.format(self.tag_name) 80 | 81 | def clean(self): 82 | old_instance = None 83 | if self.pk: 84 | old_instance = self.__class__._default_manager.get(pk=self.pk) 85 | 86 | parser = get_parser() 87 | 88 | tag_re = bbcodde_standard_re if not self.standalone else bbcodde_standalone_re 89 | valid_bbcode_tag = re.search(tag_re, self.tag_definition) 90 | def_placeholders = re.findall(placeholder_re, self.tag_definition) 91 | 92 | # First, try to validate the tag according to the correct regex 93 | if not valid_bbcode_tag: 94 | raise ValidationError(_('The BBCode definition you provided is not valid')) 95 | re_groups = re.search(tag_re, self.tag_definition).groupdict() 96 | 97 | # Validates the tag definition by trying to create the corresponding BBCode class 98 | try: 99 | self.get_parser_tag_klass(tag_name=re_groups['start_name']) 100 | except Exception as e: 101 | raise ValidationError(e) 102 | 103 | if re_groups['start_name'] in parser.bbcodes.keys() \ 104 | and not hasattr(parser.bbcodes[re_groups['start_name']], 'default_tag') \ 105 | and not ( 106 | old_instance is not None and old_instance.tag_name == re_groups['start_name']): 107 | raise ValidationError(_('A BBCode tag with this name appears to already exist')) 108 | 109 | # Moreover, the used placeholders must be known by the BBCode parser and they must have the 110 | # same name, with some variations: eg {TEXT} can be used as {TEXT1} or {TEXT2} if two 'TEXT' 111 | # placeholders are needed 112 | placeholder_types = [ 113 | re.findall(placeholder_content_re, placeholder) for placeholder in def_placeholders] 114 | placeholder_types = [ 115 | placeholder_data[0][0] for placeholder_data in placeholder_types if placeholder_data] 116 | valid_placeholder_types = [ 117 | placeholder for placeholder in placeholder_types 118 | if placeholder in parser.placeholders.keys()] 119 | 120 | if (not len(valid_placeholder_types) and not self.standalone) \ 121 | or valid_placeholder_types != placeholder_types: 122 | raise ValidationError( 123 | _('You can only use placeholder names among: ' + str(parser.placeholders.keys()) + 124 | '. If you need many placeholders of a specific type, you can append numbers to ' 125 | 'them (eg. {TEXT1} or {TEXT2})')) 126 | 127 | super(BBCodeTag, self).clean() 128 | 129 | def save(self, *args, **kwargs): 130 | tag_re = bbcodde_standard_re if not self.standalone else bbcodde_standalone_re 131 | # Generate the tag name according to the tag definition 132 | re_groups = re.search(tag_re, self.tag_definition).groupdict() 133 | self.tag_name = re_groups['start_name'] 134 | 135 | super(BBCodeTag, self).save(*args, **kwargs) 136 | # Ok, now the tag should be added to the BBCode parser for later use 137 | parser_tag_klass = self.parser_tag_klass 138 | parser = get_parser() 139 | parser.add_bbcode_tag(parser_tag_klass) 140 | 141 | def delete(self, *args, **kwargs): 142 | tag_name = self.tag_name 143 | super(BBCodeTag, self).delete(*args, **kwargs) 144 | 145 | # Remove the deleted tag from the BBCode parser pool of 146 | # available bbcode tags 147 | parser = get_parser() 148 | parser.bbcodes.pop(tag_name) 149 | 150 | def get_parser_tag_klass(self, tag_name=None): 151 | # Construct the inner Options class 152 | opts = self._meta 153 | tag_option_attrs = vars(BBCodeTagOptions) 154 | options_klass_attrs = { 155 | f.name: f.value_from_object(self) for f in opts.fields if f.name in tag_option_attrs} 156 | options_klass = type(force_str('Options'), (), options_klass_attrs) 157 | # Construct the outer BBCodeTag class 158 | tag_klass_attrs = { 159 | 'name': self.tag_name if not tag_name else tag_name, 160 | 'definition_string': self.tag_definition, 161 | 'format_string': self.html_replacement, 162 | 'Options': options_klass, 163 | } 164 | tag_klass = type( 165 | force_str('{}Tag'.format(self.tag_name)), (ParserBBCodeTag, ), tag_klass_attrs) 166 | return tag_klass 167 | 168 | @property 169 | def parser_tag_klass(self): 170 | return self.get_parser_tag_klass() 171 | 172 | 173 | class SmileyTag(models.Model): 174 | code = SmileyCodeField(max_length=60, verbose_name=_('Smiley code'), unique=True) 175 | image = models.ImageField( 176 | verbose_name=_('Smiley icon'), upload_to=bbcode_settings.SMILIES_UPLOAD_TO) 177 | image_width = models.PositiveIntegerField( 178 | verbose_name=_('Smiley icon width'), null=True, blank=True) 179 | image_height = models.PositiveIntegerField( 180 | verbose_name=_('Smiley icon height'), null=True, blank=True) 181 | 182 | # For later use 183 | emotion = models.CharField( 184 | max_length=100, verbose_name=_('Related emotion'), null=True, blank=True) 185 | display_on_editor = models.BooleanField(verbose_name=_('Display on editor'), default=True) 186 | 187 | class Meta: 188 | verbose_name = _('Smiley') 189 | verbose_name_plural = _('Smilies') 190 | app_label = 'precise_bbcode' 191 | 192 | def __str__(self): 193 | return '{}'.format(self.code) 194 | 195 | def save(self, *args, **kwargs): 196 | super(SmileyTag, self).save(*args, **kwargs) 197 | # The smiley should be added to the BBCode parser for later use 198 | parser = get_parser() 199 | parser.add_smiley(self.code, self.html_code) 200 | 201 | @property 202 | def html_code(self): 203 | """ 204 | Returns the HTML associated with the current smiley tag object. 205 | """ 206 | width = self.image_width or 'auto' 207 | height = self.image_height or 'auto' 208 | emotion = self.emotion or '' 209 | img = '{}'.format( 210 | self.image.url, width, height, emotion) 211 | return img 212 | -------------------------------------------------------------------------------- /precise_bbcode/bbcode/tag.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | 4 | from precise_bbcode.bbcode.exceptions import InvalidBBCodePlaholder 5 | from precise_bbcode.bbcode.exceptions import InvalidBBCodeTag 6 | from precise_bbcode.bbcode.regexes import bbcodde_standalone_re 7 | from precise_bbcode.bbcode.regexes import bbcodde_standard_re 8 | from precise_bbcode.bbcode.regexes import placeholder_content_re 9 | from precise_bbcode.bbcode.regexes import placeholder_re 10 | from precise_bbcode.conf import settings as bbcode_settings 11 | from precise_bbcode.core.compat import with_metaclass 12 | from precise_bbcode.core.utils import replace 13 | 14 | 15 | class BBCodeTagBase(type): 16 | """ 17 | Metaclass for all BBCode tags. 18 | This metaclass ensure that the BBCode tags subclasses have the required values 19 | and proceed to some validations. 20 | """ 21 | def __new__(cls, name, bases, attrs): 22 | super_new = super(BBCodeTagBase, cls).__new__ 23 | parents = [base for base in bases if isinstance(base, BBCodeTagBase)] 24 | 25 | if not parents: 26 | # Stop here if we are considering the top-level class to which the 27 | # current metaclass was applied and not one of its subclasses. 28 | # eg. BBCodeTag 29 | return super_new(cls, name, bases, attrs) 30 | 31 | # Pop the option metadata from the class attributes 32 | options_klass = attrs.pop('Options', None) 33 | 34 | # Construct the BBCode tag class 35 | new_tag = super_new(cls, name, bases, attrs) 36 | 37 | # Validates the tag name 38 | if not hasattr(new_tag, 'name'): 39 | raise InvalidBBCodeTag( 40 | 'BBCodeTag subclasses must have a \'name\' attribute' 41 | ) 42 | if not new_tag.name: 43 | raise InvalidBBCodeTag( 44 | 'The \'name\' attribute associated with BBCodeTag subclasses cannot be None' 45 | ) 46 | if not re.match('^[^\s=]+$', new_tag.name): 47 | raise InvalidBBCodeTag( 48 | """The \'name\' attribute associated with {!r} is not valid: a tag name must be 49 | strictly composed of non-white-space characters and the '=' character is not 50 | allowed""".format(name) 51 | ) 52 | 53 | # Initializes the '_options' attribute 54 | if options_klass: 55 | option_attrs = inspect.getmembers(options_klass, lambda a: not(inspect.isroutine(a))) 56 | options_kwargs = dict( 57 | [a for a in option_attrs if not(a[0].startswith('__') and a[0].endswith('__'))]) 58 | setattr(new_tag, '_options', BBCodeTagOptions(**options_kwargs)) 59 | else: 60 | setattr(new_tag, '_options', BBCodeTagOptions()) 61 | 62 | # Validates the BBCode definition: a BBCode class with a definition string cannot be 63 | # created without a format string. The reverse is also true. 64 | if (new_tag.definition_string and not new_tag.format_string) \ 65 | or (not new_tag.definition_string and new_tag.format_string): 66 | raise InvalidBBCodeTag( 67 | """{!r} is not valid: the \'definition_string\' attribute cannot be specified without defining 68 | the related \'format_string\'""".format(name) 69 | ) 70 | 71 | if new_tag.definition_string and new_tag.format_string: 72 | # Check whether the tag is correctly defined according to a bbcode tag regex 73 | tag_re = bbcodde_standard_re if not new_tag._options.standalone \ 74 | else bbcodde_standalone_re 75 | valid_bbcode_tag = re.search(tag_re, new_tag.definition_string) 76 | if not valid_bbcode_tag: 77 | raise InvalidBBCodeTag('The BBCode definition you provided is not valid') 78 | 79 | re_groups = re.search(tag_re, new_tag.definition_string).groupdict() 80 | 81 | # The beginning and end tag names must be the same 82 | if not (new_tag._options.standalone or new_tag._options.newline_closes or 83 | new_tag._options.same_tag_closes or new_tag._options.end_tag_closes) \ 84 | and re_groups['start_name'] != re_groups['end_name']: 85 | raise InvalidBBCodeTag( 86 | 'This BBCode tag dit not validate because the start tag and the tag names are ' 87 | 'not the same') 88 | 89 | # The used placeholders must be the same in the tag definition and in the HTML 90 | # replacement code 91 | def_placeholders = re.findall(placeholder_re, new_tag.definition_string) 92 | html_placeholders = re.findall(placeholder_re, new_tag.format_string) 93 | if set(def_placeholders) != set(html_placeholders): 94 | raise InvalidBBCodeTag( 95 | 'The placeholders defined in the tag definition must be present in the HTML ' 96 | 'replacement code!') 97 | 98 | # ... and two placeholders must not have the same name 99 | def_placeholders_uniques = list(set(def_placeholders)) 100 | if sorted(def_placeholders) != sorted(def_placeholders_uniques): 101 | raise InvalidBBCodeTag( 102 | 'The placeholders defined in the tag definition must be strictly uniques') 103 | 104 | return new_tag 105 | 106 | 107 | class BBCodeTag(with_metaclass(BBCodeTagBase)): 108 | name = None 109 | definition_string = None 110 | format_string = None 111 | 112 | def do_render(self, parser, value, option=None, parent=None): 113 | """ 114 | This method is called by the BBCode parser to render the content of 115 | each BBCode tag. 116 | The default implementation will use a generic rendering method if the 117 | BBCode tag is defined by a definition string and a format string. In 118 | any other case, the 'render' method will be called. The latest should 119 | be overidden in any subclasses that is not based on a definition string 120 | and a format string. 121 | """ 122 | if self.definition_string and self.format_string: 123 | return self._render_default(parser, value, option, parent) 124 | return self.render(value, option, parent) 125 | 126 | def render(self, value, option=None, parent=None): 127 | """ 128 | The render function is used to transform a BBCodeTag and its context (value, option) to 129 | the corresponding HTML output. 130 | 131 | value 132 | The context between start and end tags, or None for standalone tags. 133 | Whether this has been rendered depends on render_embedded tag option. 134 | option 135 | The value of an option passed to the tag. 136 | parent 137 | The parent BBCodeTag instance, if the tag is being rendered inside another tag, 138 | otherwise None. 139 | """ 140 | # The default implementation will raise a NotImplementedError to ensure 141 | # that any subclasses must override this method if the definition string 142 | # and the format string are not used. 143 | raise NotImplementedError 144 | 145 | def _render_default(self, parser, value, option=None, parent=None): 146 | placeholders = re.findall(placeholder_re, self.definition_string) 147 | # Get the format data 148 | fmt = {} 149 | if len(placeholders) == 1: 150 | fmt.update({placeholders[0]: value}) 151 | elif len(placeholders) == 2: 152 | fmt.update({ 153 | placeholders[1]: value, 154 | placeholders[0]: replace(option, bbcode_settings.BBCODE_ESCAPE_HTML) if option else '' # noqa 155 | }) 156 | 157 | # Semantic validation 158 | valid = self._validate_format(parser, fmt) 159 | if not valid and option: 160 | return self.definition_string.format(**fmt) 161 | elif not valid: 162 | return self.definition_string.format(**fmt).replace('=', '') 163 | 164 | # Before rendering, it's necessary to escape the included braces: '{' and '}' ; some of them 165 | # could not be placeholders 166 | escaped_format_string = self.format_string.replace('{', '{{').replace('}', '}}') 167 | for placeholder in fmt.keys(): 168 | escaped_format_string = escaped_format_string.replace( 169 | '{' + placeholder + '}', placeholder) 170 | 171 | # Return the rendered data 172 | return escaped_format_string.format(**fmt) 173 | 174 | def _validate_format(self, parser, format_dict): 175 | """ 176 | Validates the given format dictionary. Each key of this dict refers to a specific BBCode 177 | placeholder type. 178 | eg. {TEXT} or {TEXT1} refer to the 'TEXT' BBCode placeholder type. 179 | Each content is validated according to its associated placeholder type. 180 | """ 181 | for placeholder_string, content in format_dict.items(): 182 | try: 183 | placeholder_results = re.findall(placeholder_content_re, placeholder_string) 184 | assert len(placeholder_results) 185 | placeholder_type, _, extra_context = placeholder_results[0] 186 | valid_content = parser.placeholders[placeholder_type.upper()].validate( 187 | content, extra_context=extra_context[1:]) 188 | assert valid_content and valid_content is not None 189 | except KeyError: 190 | raise InvalidBBCodePlaholder(placeholder_type) 191 | except AssertionError: 192 | return False 193 | return True 194 | 195 | 196 | class BBCodeTagOptions(object): 197 | # Force the closing of this tag after a newline 198 | newline_closes = False 199 | # Force the closing of this tag after the start of the same tag 200 | same_tag_closes = False 201 | # Force the closing of this tag after the end of another tag 202 | end_tag_closes = False 203 | # This tag does not have a closing tag 204 | standalone = False 205 | # The embedded tags will be rendered 206 | render_embedded = True 207 | # The embedded newlines will be converted to markup 208 | transform_newlines = True 209 | # The HTML characters inside this tag will be escaped 210 | escape_html = True 211 | # Replace URLs with link markups inside this tag 212 | replace_links = True 213 | # Strip leading and trailing whitespace inside this tag 214 | strip = False 215 | # Swallow the first trailing newline 216 | swallow_trailing_newline = False 217 | 218 | # The following options will be usefull for BBCode editors 219 | helpline = None 220 | display_on_editor = True 221 | 222 | def __init__(self, **kwargs): 223 | for attr, value in kwargs.items(): 224 | setattr(self, attr, value) 225 | -------------------------------------------------------------------------------- /docs/extending_precise_bbcode/custom_tags.rst: -------------------------------------------------------------------------------- 1 | ################## 2 | Custom BBCode tags 3 | ################## 4 | 5 | While *django-precise-bbcode* comes with some built-in BBCode tags, there will be times when you need to add your own. 6 | 7 | Defining BBCode tags through the admin site 8 | ------------------------------------------- 9 | 10 | *The easy way.* 11 | 12 | *Django-precise-bbcode* provides a ``BBCodeTag`` model which can be seen as a helper to allow end users to easily define BBCode tags. Just go to the admin page and you will see a new 'BBCodes' section. In this section you can create and edit custom BBCode tags. These are then used by the built-in BBCode parser to render any BBCode content. 13 | 14 | Adding a custom BBCode tag consists in defining at least two values in the associated admin form, both its usage (tag definition) and its HTML replacement code. 15 | 16 | Tag definition 17 | ~~~~~~~~~~~~~~~ 18 | 19 | The tag definition is the expression of the bbcode usage. It's where you enter your bbcode. All you need to do is to add a string containing your BBCode and the associated placeholders (special uppercase words surrounded by { and } -- they are similar to the "replacement fields" that you define in Python format strings):: 20 | 21 | [foo]{TEXT}[/foo] 22 | 23 | In this example, we have a bbcode named ``foo`` which can contain some text (``{TEXT}`` placeholder). 24 | 25 | So a bbcode definition takes the form of what users will enter when using this bbcode, except that all parts of the bbcode where data is required are expressed as placeholders. The placeholders that you can use in such a tag definition are typed. This means that some semantic verifications are done before rendering in order to ensure that data containing non-allowed characters are not converted to HTML. 26 | 27 | *Django-precise-bbcode* provides some placeholders by default, such as ``{TEXT}``, ``{SIMPLETEXT}``, ``{COLOR}``, ``{NUMBER}``, ``{URL}`` or ``{EMAIL}``. For a full list of the available placeholders and the techniques used to define custom placeholders, please refer to :doc:`custom_placeholders`. 28 | 29 | Note that you can specify an option to your bbcode in its definition:: 30 | 31 | [foo={COLOR}]{TEXT}[/foo] 32 | 33 | In this case, the data associated with the ``{COLOR}`` placeholder is not required at runtime. If you wish to use two placeholders of the same type in your bbcode definition, you have to append a number to their respective names (eg. ``{TEXT1}``):: 34 | 35 | [foo={TEXT1}]{TEXT2}[/foo] 36 | 37 | HTML replacement code 38 | ~~~~~~~~~~~~~~~~~~~~~ 39 | 40 | The HTML replacement code is where you enter the HTML for the bbcode you defined previously. All the placeholders you used in your bbcode definition must be present in the HTML replacement code. For example, the HTML replacement code associated with the last ``[foo]`` bbcode example could be:: 41 | 42 |
    {TEXT}
    43 | 44 | BBCode options 45 | ~~~~~~~~~~~~~~ 46 | 47 | Some specific options can be used when defining a custom bbcode to alter its default behavior. For example, you could want to forbid the rendering of any bbcode tags included inside your new bbcode. All these options are boolean fields and are indicated in the following table: 48 | 49 | +--------------------------+-----------------------------------------------------------------+-------------+ 50 | | Option | Definition | Default | 51 | +==========================+=================================================================+=============+ 52 | | newline_closes | Force the closing of a tag after a newline | False | 53 | +--------------------------+-----------------------------------------------------------------+-------------+ 54 | | same_tag_closes | Force the closing of a tag after the beginning of a similar tag | False | 55 | +--------------------------+-----------------------------------------------------------------+-------------+ 56 | | end_tag_closes | Force the closing of a tag after the end of another tag | False | 57 | +--------------------------+-----------------------------------------------------------------+-------------+ 58 | | standalone | Set this option if a tag does not have a closing tag (eg. [hr]) | False | 59 | +--------------------------+-----------------------------------------------------------------+-------------+ 60 | | transform_newlines | Convert any line break to the equivalent markup | True | 61 | +--------------------------+-----------------------------------------------------------------+-------------+ 62 | | render_embedded | Force the tags embedded in a tag to be rendered | True | 63 | +--------------------------+-----------------------------------------------------------------+-------------+ 64 | | escape_html | Escape HTML characters (<, >, and &) inside a tag | True | 65 | +--------------------------+-----------------------------------------------------------------+-------------+ 66 | | replace_links | Replace URLs with link markups inside a tag | True | 67 | +--------------------------+-----------------------------------------------------------------+-------------+ 68 | | strip | Strip leading and trailing whitespace inside a tag | False | 69 | +--------------------------+-----------------------------------------------------------------+-------------+ 70 | | swallow_trailing_newline | Swallow the first trailing newline inside a tag | False | 71 | +--------------------------+-----------------------------------------------------------------+-------------+ 72 | 73 | Defining BBCode tags plugins 74 | ---------------------------- 75 | 76 | *The fun part.* 77 | 78 | While the previous bbcode tag system allows you to easily define various bbcodes, you may want to do more complex treatments with your bbcodes (eg. handle other types of data). You may also want to write some **reusable** or **generic** bbcode tags. 79 | 80 | To do so, you will have to write a subclass of ``precise_bbcode.bbcode.tag.BBCodeTag`` for any tag you want to create. These class-based bbcode tags must be defined inside a ``bbcode_tags`` Python module in your Django application (just add a file called ``bbcode_tags.py`` to an existing Django application). And last, but not least, your class-based bbcode tags must be registered to the ``precise_bbcode.tag_pool.tag_pool`` object by using its ``register_tag`` method to be available to the BBCode parser. 81 | 82 | Each of these tags must provide a ``name`` attribute and can operate in two different ways: 83 | 84 | * The ``BBCodeTag`` subclass provides a ``definition_string`` attribute and a ``format_string`` attribute. In this case, the tag will operate as previously described. The ``definition_string`` corresponds to the tag definition and defines how the tag should be used. The ``format_string`` is the HTML replacement code that will be used to generate the final HTML output 85 | * The ``BBCodeTag`` subclass implements a ``render`` method that will be used to transform the bbcode tag and its context (value, option if provided) to the corresponding HTML output 86 | 87 | Defining bbcodes based on a definition string and a format string 88 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 89 | 90 | In this case, you have to provide a ``definition_string`` attribute and a ``format_string`` attribute to your ``BBCodeTag`` subclass, in addition to the ``name`` attribute. 91 | 92 | Let's write a simple example. Consider we are trying to write a ``bar`` bbcode which will strike any text placed inside its tags. So we could write:: 93 | 94 | # bbcode_tags.py 95 | from precise_bbcode.bbcode.tag import BBCodeTag 96 | from precise_bbcode.tag_pool import tag_pool 97 | 98 | class BarBBCodeTag(BBCodeTag): 99 | name = 'bar' 100 | definition_string = '[bar]{TEXT}[/bar]' 101 | format_string = '{TEXT}' 102 | 103 | tag_pool.register_tag(BarBBCodeTag) 104 | 105 | Note that you can use any BBCode options specified previously to alter the default behavior of your class-based tags (see `BBCode options`_). To do so, give your bbcode tag options by using an inner class ``Options``, like so:: 106 | 107 | # bbcode_tags.py 108 | from precise_bbcode.bbcode.tag import BBCodeTag 109 | from precise_bbcode.tag_pool import tag_pool 110 | 111 | class BarBBCodeTag(BBCodeTag): 112 | name = 'bar' 113 | definition_string = '[bar]{TEXT}[/bar]' 114 | format_string = '{TEXT}' 115 | 116 | class Options: 117 | render_embedded = False 118 | strip = False 119 | 120 | tag_pool.register_tag(BarBBCodeTag) 121 | 122 | Defining bbcodes based on a ``render`` method 123 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 124 | 125 | In this case, each of your ``BBCodeTag`` subclasses must provide a ``name`` attribute and must implement a ``render`` method. The ``render`` method is used to transform your bbcode tag and its context (value, option if provided) to the corresponding HTML output. The ``render`` method takes three arguments: 126 | 127 | * **value**: the context between the start end the end tags, or None for standalone tags. Whether this has been rendered depends on the ``render_embedded`` tag option 128 | * **option**: The value of an option passed to the tag ; defaults to None 129 | * **parent**: The options (instance of ``precise_bbcode.bbcode.tag.BBCodeTagOptions``) associated with the parent bbcode if the tag is being rendered inside another tag, otherwise None 130 | 131 | Keep in mind that your ``render`` method may have to validate the data associated with your tag before rendering it. Any validation process should be triggered from this ``render`` method. 132 | 133 | Let's write another example. Consider we are trying to write a ``rounded`` bbcode which will surround inside a rounded frame any text placed inside the tags. If provided, the option passed to the tag is assumed to be a colour in order to modify the resulting HTML code. So we could write:: 134 | 135 | # bbcode_tags.py 136 | import re 137 | from precise_bbcode.bbcode.tag import BBCodeTag 138 | from precise_bbcode.tag_pool import tag_pool 139 | 140 | color_re = re.compile(r'^([a-z]+|#[0-9abcdefABCDEF]{3,6})$') 141 | 142 | class RoundedBBCodeTag(BBCodeTag): 143 | name = 'rounded' 144 | 145 | class Options: 146 | strip = False 147 | 148 | def render(self, value, option=None, parent=None): 149 | if option and re.search(color_re, option) is not None: 150 | return '
    {}
    '.format(option, value) 151 | return '
    {}
    '.format(value) 152 | 153 | tag_pool.register_tag(RoundedBBCodeTag) 154 | 155 | Again, you can use any BBCode options as previously stated (see `BBCode options`_). 156 | 157 | Overriding default BBCode tags 158 | ------------------------------ 159 | 160 | When loaded, the parser provided by *django-precise-bbcode* provides some default bbcode tags (please refer to :doc:`../basic_reference/builtin_bbcodes` for the full list of default tags). These default tags can be overriden. You just have to create another tag with the same name either by defining it in the admin site or by defining it in a ``bbcode_tags`` Python module as previously explained. -------------------------------------------------------------------------------- /tests/unit/test_placeholders.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from precise_bbcode.bbcode import BBCodeParserLoader 7 | from precise_bbcode.bbcode import get_parser 8 | from precise_bbcode.bbcode.defaults.placeholder import _color_re 9 | from precise_bbcode.bbcode.defaults.placeholder import _email_re 10 | from precise_bbcode.bbcode.defaults.placeholder import _number_re 11 | from precise_bbcode.bbcode.defaults.placeholder import _simpletext_re 12 | from precise_bbcode.bbcode.defaults.placeholder import _text_re 13 | from precise_bbcode.bbcode.exceptions import InvalidBBCodePlaholder 14 | from precise_bbcode.bbcode.placeholder import BBCodePlaceholder 15 | from precise_bbcode.bbcode.regexes import url_re 16 | from precise_bbcode.bbcode.tag import BBCodeTag 17 | from precise_bbcode.placeholder_pool import PlaceholderAlreadyRegistered 18 | from precise_bbcode.placeholder_pool import PlaceholderNotRegistered 19 | from precise_bbcode.placeholder_pool import placeholder_pool 20 | from precise_bbcode.tag_pool import tag_pool 21 | 22 | 23 | class SizeTag(BBCodeTag): 24 | name = 'siz' 25 | definition_string = '[siz={RANGE=4,7}]{TEXT}[/siz]' 26 | format_string = '{TEXT}' 27 | 28 | 29 | class ErroredSizeTag(BBCodeTag): 30 | name = 's2' 31 | definition_string = '[s2={RANGE=a,7}]{TEXT}[/s2]' 32 | format_string = '{TEXT}' 33 | 34 | 35 | class DayTag(BBCodeTag): 36 | name = 'day' 37 | definition_string = ( 38 | '[day]{CHOICE=monday,tuesday,wednesday,tuesday,friday,saturday,sunday}[/day]' 39 | ) 40 | format_string = '
    {CHOICE=monday,tuesday,wednesday,tuesday,friday,saturday,sunday}
    ' 41 | 42 | 43 | class FooPlaceholder(BBCodePlaceholder): 44 | name = 'foo' 45 | pattern = re.compile(r'^[\d]*$') 46 | 47 | 48 | class DummyPlaceholder(BBCodePlaceholder): 49 | name = 'dummy' 50 | pattern = re.compile(r'^[\w]*$') 51 | 52 | 53 | class FooBBCodeTag(BBCodeTag): 54 | name = 'xyz' 55 | definition_string = '[xyz]{FOO}[/xyz]' 56 | format_string = '{FOO}' 57 | 58 | 59 | class DummyBBCodeTag(BBCodeTag): 60 | name = 'dummy' 61 | definition_string = '[dummy]{DUMMY}[/dummy]' 62 | format_string = '{DUMMY}12'), 70 | ) 71 | 72 | def setup_method(self, method): 73 | self.parser = get_parser() 74 | 75 | def test_should_raise_if_a_placeholder_is_registered_twice(self): 76 | # Setup 77 | number_of_placeholders_before = len(placeholder_pool.get_placeholders()) 78 | placeholder_pool.register_placeholder(FooPlaceholder) 79 | # Run & check 80 | # Let's add it a second time. We should catch an exception 81 | with pytest.raises(PlaceholderAlreadyRegistered): 82 | placeholder_pool.register_placeholder(FooPlaceholder) 83 | # Let's make sure we have the same number of tags as before 84 | placeholder_pool.unregister_placeholder(FooPlaceholder) 85 | number_of_placeholders_after = len(placeholder_pool.get_placeholders()) 86 | assert number_of_placeholders_before == number_of_placeholders_after 87 | 88 | def test_cannot_register_placeholders_with_incorrect_parent_classes(self): 89 | # Setup 90 | number_of_placeholders_before = len(placeholder_pool.get_placeholders()) 91 | # Run & check 92 | with pytest.raises(ImproperlyConfigured): 93 | class ErrnoneousPlaceholder: 94 | pass 95 | placeholder_pool.register_placeholder(ErrnoneousPlaceholder) 96 | number_of_placeholders_after = len(placeholder_pool.get_placeholders()) 97 | assert number_of_placeholders_before == number_of_placeholders_after 98 | 99 | def test_cannot_unregister_a_non_registered_placeholder(self): 100 | # Setup 101 | number_of_placeholders_before = len(placeholder_pool.get_placeholders()) 102 | # Run & check 103 | with pytest.raises(PlaceholderNotRegistered): 104 | placeholder_pool.unregister_placeholder(FooPlaceholder) 105 | number_of_placeholders_after = len(placeholder_pool.get_placeholders()) 106 | assert number_of_placeholders_before == number_of_placeholders_after 107 | 108 | def test_placeholders_can_be_used_with_tags(self): 109 | # Setup 110 | parser_loader = BBCodeParserLoader(parser=self.parser) 111 | placeholder_pool.register_placeholder(FooPlaceholder) 112 | placeholder_pool.register_placeholder(DummyPlaceholder) 113 | tag_pool.register_tag(FooBBCodeTag) 114 | tag_pool.register_tag(DummyBBCodeTag) 115 | parser_loader.init_bbcode_placeholders() 116 | parser_loader.init_bbcode_tags() 117 | # Run & check 118 | for bbcodes_text, expected_html_text in self.TAGS_TESTS: 119 | result = self.parser.render(bbcodes_text) 120 | assert result == expected_html_text 121 | placeholder_pool.unregister_placeholder(FooPlaceholder) 122 | placeholder_pool.unregister_placeholder(DummyPlaceholder) 123 | 124 | 125 | class TestPlaceholder(object): 126 | DEFAULT_PLACEHOLDERS_RE_TESTS = { 127 | 'text': { 128 | 're': _text_re, 129 | 'tests': ( 130 | 'hello world', 131 | 'hello\nworld', 132 | ' hello world ', 133 | 'http://asdf.xxxx.yyyy.com/vvvvv/PublicPages/Login.aspx?ReturnUrl=%2fvvvvv%2f' 134 | '(asdf@qwertybean.com/qwertybean)', 135 | '12902', 136 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pretium, mi ac ' 137 | '"molestie ornare, urna sem fermentum erat, malesuada interdum sapien turpis sit ' 138 | 'amet eros.\nPhasellus quis mi velit. Cras porttitor dui faucibus rhoncus ' 139 | 'fringilla. Cras non fringilla est. \nCurabitur sollicitudin nisi quis sem ' 140 | 'sodales, quis blandit massa rhoncus. Nam porta at lacus semper gravida.\n', 141 | '안녕하세요!', 142 | ) 143 | }, 144 | 'simpletext': { 145 | 're': _simpletext_re, 146 | 'tests': ( 147 | 'hello world', 148 | 'slugify-u-21' 149 | 'hello91', 150 | ) 151 | }, 152 | 'url': { 153 | 're': url_re, 154 | 'tests': ( 155 | 'http://foo.com/blah_blah', 156 | '(Something like http://foo.com/blah_blah)', 157 | 'http://foo.com/blah_blah_(wikipedia)', 158 | 'http://foo.com/more_(than)_one_(parens)', 159 | '(Something like http://foo.com/blah_blah_(wikipedia))', 160 | 'http://foo.com/blah_(wikipedia)#cite-1', 161 | 'http://foo.com/blah_(wikipedia)_blah#cite-1', 162 | 'http://foo.com/(something)?after=parens', 163 | 'http://foo.com/blah_blah.', 164 | 'http://foo.com/blah_blah/.', 165 | '', 166 | '', 167 | 'http://foo.com/blah_blah,', 168 | 'http://www.extinguishedscholar.com/wpglob/?p=364.', 169 | 'http://example.com', 170 | 'Just a www.example.com link.', 171 | 'http://example.com/something?with,commas,in,url, but not at end', 172 | 'bit.ly/foo', 173 | 'http://asdf.xxxx.yyyy.com/vvvvv/PublicPages/Login.aspx?ReturnUrl=%2fvvvvv%2f' 174 | '(asdf@qwertybean.com/qwertybean)', 175 | 'http://something.xx:8080' 176 | ) 177 | }, 178 | 'email': { 179 | 're': _email_re, 180 | 'tests': ( 181 | 'president@whitehouse.gov', 182 | 'xyz.xyz@xy.com', 183 | 'hello_world@rt.rt', 184 | '"email"@domain.com', 185 | 'a@b.cc', 186 | 'joe@aol.com', 187 | 'joe@wrox.co.uk', 188 | 'joe@domain.info', 189 | 'asmith@mactec.com', 190 | 'foo12@foo.edu ', 191 | 'bob.smith@foo.tv', 192 | 'bob-smith@foo.com', 193 | 'bob.smith@foo.com', 194 | 'bob_smith@foo.com', 195 | 'bob@somewhere.com', 196 | 'bob.jones@[1.1.1.1]', 197 | 'bob@a.b.c.d.info', 198 | '<ab@cd.ef>', 199 | 'bob A. jones <ab@cd.ef>', 200 | 'bob A. jones <ab@[1.1.1.111]>', 201 | 'blah@127.0.0.1', 202 | 'whatever@somewhere.museum', 203 | 'foreignchars@myforeigncharsdomain.nu', 204 | 'me+mysomething@mydomain.com', 205 | 'u-s_e.r1@s-ub2.domain-name.museum:8080', 206 | ) 207 | }, 208 | 'color': { 209 | 're': _color_re, 210 | 'tests': ( 211 | 'red', 212 | 'blue', 213 | 'pink', 214 | '#FFFFFF', 215 | '#fff000', 216 | '#FFF', 217 | '#3089a2', 218 | ) 219 | }, 220 | 'number': { 221 | 're': _number_re, 222 | 'tests': ( 223 | '12', 224 | '1289101', 225 | '-121', 226 | '89.12', 227 | '100000000000001', 228 | '10000000000000,1', 229 | '-12,1990000000000000001', 230 | ) 231 | } 232 | } 233 | 234 | DEFAULT_PLACEHOLDERS_TESTS = ( 235 | ('[siz=4]hello world![/siz]', 'hello world!'), 236 | ('[siz=5]hello world![/siz]', 'hello world!'), 237 | ('[siz=6]hello world![/siz]', 'hello world!'), 238 | ('[siz=7]hello world![/siz]', 'hello world!'), 239 | ('[siz=3]hello world![/siz]', '[siz=3]hello world![/siz]'), 240 | ('[siz=8]hello world![/siz]', '[siz=8]hello world![/siz]'), 241 | ('[siz=test]hello world![/siz]', '[siz=test]hello world![/siz]'), 242 | ('[day]tuesday[/day]', '
    tuesday
    '), 243 | ('[day]monday[/day]', '
    monday
    '), 244 | ('[day]sunday[/day]', '
    sunday
    '), 245 | ('[day]sun[/day]', '[day]sun[/day]'), 246 | ('[day]test, test[/day]', '[day]test, test[/day]'), 247 | ) 248 | 249 | ERRORED_DEFAULT_PLACEHOLDERS_TESTS = ( 250 | ('[s2=4]hello world![/s2]', '[s2=4]hello world![/s2]'), 251 | ) 252 | 253 | def setup_method(self, method): 254 | self.parser = get_parser() 255 | self.parser.add_bbcode_tag(SizeTag) 256 | self.parser.add_bbcode_tag(ErroredSizeTag) 257 | self.parser.add_bbcode_tag(DayTag) 258 | 259 | def test_regexes_provided_by_default_are_valid(self): 260 | # Run & check 261 | for _, re_tests in self.DEFAULT_PLACEHOLDERS_RE_TESTS.items(): 262 | for test in re_tests['tests']: 263 | assert re.search(re_tests['re'], test) is not None 264 | 265 | def test_provided_by_default_are_valid(self): 266 | for bbcodes_text, expected_html_text in self.DEFAULT_PLACEHOLDERS_TESTS: 267 | result = self.parser.render(bbcodes_text) 268 | assert result == expected_html_text 269 | 270 | def test_provided_by_default_cannot_be_rendered_if_they_are_not_used_correctly(self): 271 | for bbcodes_text, expected_html_text in self.ERRORED_DEFAULT_PLACEHOLDERS_TESTS: 272 | result = self.parser.render(bbcodes_text) 273 | assert result == expected_html_text 274 | 275 | def test_that_are_invalid_should_raise_at_runtime(self): 276 | # Run & check 277 | with pytest.raises(InvalidBBCodePlaholder): 278 | class InvalidePlaceholder1(BBCodePlaceholder): 279 | pass 280 | with pytest.raises(InvalidBBCodePlaholder): 281 | class InvalidePlaceholder2(BBCodePlaceholder): 282 | delattr(BBCodePlaceholder, 'name') 283 | with pytest.raises(InvalidBBCodePlaholder): 284 | class InvalidePlaceholder3(BBCodePlaceholder): 285 | name = 'bad placeholder name' 286 | with pytest.raises(InvalidBBCodePlaholder): 287 | class InvalidePlaceholder4(BBCodePlaceholder): 288 | name = 'correctname' 289 | pattern = 'incorrect pattern' 290 | -------------------------------------------------------------------------------- /tests/unit/test_parser.py: -------------------------------------------------------------------------------- 1 | from precise_bbcode.bbcode import get_parser 2 | from precise_bbcode.test import gen_bbcode_tag_klass 3 | 4 | 5 | class TestParser(object): 6 | DEFAULT_TAGS_RENDERING_TESTS = ( 7 | # BBcodes without errors 8 | ('[b]hello world![/b]', 'hello world!'), 9 | ('[b]hello [i]world![/i][/b]', 'hello world!'), 10 | ('[b]hello [ world![/b]', 'hello [ world!'), 11 | ('[b]]he[llo [ w]orld![/b]', ']he[llo [ w]orld!'), 12 | ('[b]]hello [b]the[/b] world![/b]', ']hello the world!'), 13 | ('[ b ]hello [u]world![/u][ /b ]', 'hello world!'), 14 | ('[b]hello [] world![/b]', 'hello [] world!'), 15 | ('[list]\n[*]one\n[*]two\n[/list]', '
    • one
    • two
    '), 16 | ( 17 | '[list=1]\n[*]item 1\n[*]item 2\n[/list]', 18 | '
    1. item 1
    2. item 2
    ' 19 | ), 20 | ( 21 | '[list] [*]Item 1 [*]Item 2 [*]Item 3 [/list]', 22 | '
    • Item 1
    • Item 2
    • Item 3
    ' 23 | ), 24 | ('>> some special chars >< <>', '>> some special chars >< <>'), 25 | ('"quoted text"', '"quoted text"'), 26 | ('>> some other special chars', '>> some other special chars'), 27 | ( 28 | '[url]http://foo.com/bar.php?some--data[/url]', 29 | 'http://foo.com/bar.php?some--data' 30 | ), 31 | ( 32 | '[url]http://www.google.com[/url]', 33 | 'http://www.google.com' 34 | ), 35 | ('[url=google.com]goto google[/url]', 'goto google'), 36 | ('[url=http://google.com][/url]', 'http://google.com'), 37 | ('[url=\'http://google.com\'][/url]', 'http://google.com'), 38 | ('[url="http://google.com"][/url]', 'http://google.com'), 39 | ('[URL=google.com]goto google[/URL]', 'goto google'), 40 | ( 41 | '[url=]xss[/url]', 42 | '[url=<script>alert(1);</script>]xss[/url]' 43 | ), 44 | ( 45 | 'www.google.com foo.com/bar http://xyz.ci', 46 | 'www.google.com ' 47 | 'foo.com/bar http://xyz.ci' 48 | ), 49 | ('[url=relative/foo/bar.html]link[/url]', '[url=relative/foo/bar.html]link[/url]'), 50 | ('[url=/absolute/foo/bar.html]link[/url]', '[url=/absolute/foo/bar.html]link[/url]'), 51 | ('[url=./hello.html]world![/url]', '[url=./hello.html]world![/url]'), 52 | ( 53 | '[url=javascript:alert(String.fromCharCode(88,83,83))]http://google.com[/url]', 54 | '[url=javascript:alert(String.fromCharCode(88,83,83))]http://google.com[/url]' 55 | ), 56 | ( 57 | '[url]http://google.com?[url] ' 58 | 'onmousemove=javascript:alert(String.fromCharCode(88,83,83));//[/url][/url]', 59 | '[url]http://google.com?[url] ' 60 | 'onmousemove=javascript:alert(String.fromCharCode(88,83,83));//[/url][/url]' 61 | ), 62 | ( 63 | '[img]http://www.foo.com/bar/img.png[/img]', 64 | '' 65 | ), 66 | ( 67 | '[img]fake.png" onerror="alert(String.fromCharCode(88,83,83))[/img]', 68 | '[img]fake.png" onerror"alert(String.fromCharCode(88,83,83))[/img]' 69 | ), 70 | ( 71 | '[img]http://foo.com/fake.png [img] ' 72 | 'onerror=javascript:alert(String.fromCharCode(88,83,83)) [/img] [/img]', 73 | '[img]http://foo.com/fake.png [img] ' 74 | 'onerrorjavascript:alert(String.fromCharCode(88,83,83)) [/img] [/img]' 75 | ), 76 | ('[quote] \r\nhello\nworld! [/quote]', '
    hello
    world!
    '), 77 | ('[code][b]hello world![/b][/code]', '[b]hello world![/b]'), 78 | ( 79 | '[color=green]goto [url=google.com]google website[/url][/color]', 80 | 'goto google website' 81 | ), 82 | ('[color=#FFFFFF]white[/color]', 'white'), 83 | ( 84 | '[color=]xss[/color]', 85 | '[color=<script></script>]xss[/color]' 86 | ), 87 | ('[COLOR=blue]hello world![/color]', 'hello world!'), 88 | ( 89 | '[color=#ff0000;font-size:100px;]XSS[/color]', 90 | '[color=#ff0000;font-size:100px;]XSS[/color]' 91 | ), 92 | ( 93 | '[color=#ff0000;xss:expression(alert(String.fromCharCode(88,83,83)));]XSS[/color]', 94 | '[color=#ff0000;xss:expression(alert(String.fromCharCode(88,83,83)));]XSS[/color]' 95 | ), 96 | ('[', '['), 97 | # BBCodes with syntactic errors 98 | ('[b]z sdf s s', '[b]z sdf s s'), 99 | ('[b][i]hello world![/b][/i]', '[i]hello world![/i]'), 100 | ('[b]hello [i]world![/i]', '[b]hello world!'), 101 | ('[color]test[/color]', '[color]test[/color]'), 102 | ('[/abcdef][/i]', '[/abcdef][/i]'), 103 | ('[b\n hello [i]the[/i] world![/b]', '[b
    hello the world![/b]'), 104 | ('[b]hello [i]the[/b] world![/i]', 'hello [i]the world![/i]'), 105 | ( 106 | '[b] hello the[u]world ![/i] see you[/b]', 107 | ' hello the[u]world ![/i] see you' 108 | ), 109 | ('[col\nor]more tests[/color]', '[col
    or]more tests[/color]'), 110 | ('[color]more tests[/color=#FFF]', '[color]more tests[/color=#FFF]'), 111 | ('[*]hello[/i]', '
  • hello
  • '), 112 | ('[url=\'\']Hello[/url]', '[url='']Hello[/url]'), # No url in quotes (empty url) 113 | ('[url=\'http://google.com][/url]', 114 | '[url='http://google.com][/url]'), # Open quote but no close in url 115 | # BBCodes with semantic errors 116 | ('[color=some words]test[/color]', '[color=some words]test[/color]'), 117 | # Unknown BBCodes 118 | ('[unknown][hello][/unknown]', '[unknown][hello][/unknown]'), 119 | ) 120 | 121 | CUSTOM_TAGS_RENDERING_TESTS = { 122 | 'tags': { 123 | 'justify': { 124 | 'Tag': { 125 | 'name': 'justify', 126 | 'definition_string': '[justify]{TEXT}[/justify]', 127 | 'format_string': '
    {TEXT}
    ', 128 | }, 129 | 'Options': {}, 130 | }, 131 | 'spoiler': { 132 | 'Tag': { 133 | 'name': 'spoiler', 134 | 'definition_string': '[spoiler]{TEXT}[/spoiler]', 135 | 'format_string': '
    {TEXT}
    ', # noqa 136 | }, 137 | 'Options': {}, 138 | }, 139 | 'youtube': { 140 | 'Tag': { 141 | 'name': 'youtube', 142 | 'definition_string': '[youtube]{TEXT}[/youtube]', 143 | 'format_string': ( 144 | '' 149 | ), 150 | }, 151 | 'Options': {}, 152 | }, 153 | 'h1': { 154 | 'Tag': { 155 | 'name': 'h1', 156 | 'definition_string': '[h1={COLOR}]{TEXT}[/h1]', 157 | 'format_string': ( 158 | '{TEXT}
    ' 162 | ), 163 | }, 164 | 'Options': {}, 165 | }, 166 | 'hr': { 167 | 'Tag': { 168 | 'name': 'hr', 169 | 'definition_string': '[hr]', 170 | 'format_string': '
    ', 171 | }, 172 | 'Options': {'standalone': True}, 173 | }, 174 | 'size': { 175 | 'Tag': { 176 | 'name': 'size', 177 | 'definition_string': '[size={NUMBER}]{TEXT}[/size]', 178 | 'format_string': '{TEXT}', 179 | }, 180 | 'Options': {}, 181 | }, 182 | 'mailto': { 183 | 'Tag': { 184 | 'name': 'email', 185 | 'definition_string': '[email]{EMAIL}[/email]', 186 | 'format_string': '{EMAIL}', 187 | }, 188 | 'Options': {'replace_links': False}, 189 | }, 190 | 'simpletext': { 191 | 'Tag': { 192 | 'name': 'simpletext', 193 | 'definition_string': '[simpletext]{SIMPLETEXT}[/simpletext]', 194 | 'format_string': '{SIMPLETEXT}', 195 | }, 196 | 'Options': {}, 197 | } 198 | }, 199 | 'tests': ( 200 | # BBcodes without errors 201 | ( 202 | '[justify]hello world![/justify]', 203 | '
    hello world!
    ' 204 | ), 205 | ( 206 | '[spoiler]hidden![/spoiler]', 207 | '
    hidden!
    ' # noqa 208 | ), 209 | ( 210 | '[youtube]ztD3mRMdqSw[/youtube]', 211 | '' # noqa 212 | ), 213 | ( 214 | '[h1=#FFF]hello world![/h1]', 215 | 'hello world!
    ' # noqa 216 | ), 217 | ('[hr]', '
    '), 218 | ('[size=24]hello world![/size]', 'hello world!'), 219 | ('[email]xyz@xyz.com[/email]', 'xyz@xyz.com'), 220 | ('[email]xyz.fr@xyz.com[/email]', 'xyz.fr@xyz.com'), 221 | ('[simpletext]hello world[/simpletext]', 'hello world'), 222 | # BBCodes with semantic errors 223 | ('[size=]hello world![/size]', '[size]hello world![/size]'), 224 | ('[size=hello]hello world![/size]', '[size=hello]hello world![/size]'), 225 | ('[email]hello world![/email]', '[email]hello world![/email]'), 226 | ('[email]http://www.google.com[/email]', '[email]http://www.google.com[/email]'), 227 | ('[email=12]24[/email]', '[email]24[/email]'), 228 | ('[simpletext]hello world![/simpletext]', '[simpletext]hello world![/simpletext]'), 229 | ('[simpletext]hello #~ world[/simpletext]', '[simpletext]hello #~ world[/simpletext]'), 230 | ) 231 | } 232 | 233 | def setup_method(self, method): 234 | self.parser = get_parser() 235 | 236 | def test_can_render_default_tags(self): 237 | # Run & check 238 | for bbcodes_text, expected_html_text in self.DEFAULT_TAGS_RENDERING_TESTS: 239 | result = self.parser.render(bbcodes_text) 240 | assert result == expected_html_text 241 | 242 | def test_can_render_custom_tags(self): 243 | # Setup 244 | for _, tag_def in self.CUSTOM_TAGS_RENDERING_TESTS['tags'].items(): 245 | self.parser.add_bbcode_tag(gen_bbcode_tag_klass(tag_def['Tag'], tag_def['Options'])) 246 | # Run & check 247 | for bbcodes_text, expected_html_text in self.CUSTOM_TAGS_RENDERING_TESTS['tests']: 248 | result = self.parser.render(bbcodes_text) 249 | assert result == expected_html_text 250 | 251 | def test_can_handle_unicode_inputs(self): 252 | # Setup 253 | src = '[center]ƒünk¥ 你好 • §tüƒƒ 你好[/center]' 254 | dst = '
    ƒünk¥ 你好 • §tüƒƒ 你好
    ' 255 | # Run & check 256 | assert self.parser.render(src) == dst 257 | -------------------------------------------------------------------------------- /tests/unit/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.core.exceptions import ValidationError 5 | from django.test import Client 6 | from django.urls import reverse 7 | 8 | from precise_bbcode.bbcode import BBCodeParser 9 | from precise_bbcode.bbcode import BBCodeParserLoader 10 | from precise_bbcode.bbcode import get_parser 11 | from precise_bbcode.bbcode.exceptions import InvalidBBCodePlaholder 12 | from precise_bbcode.bbcode.exceptions import InvalidBBCodeTag 13 | from precise_bbcode.bbcode.tag import BBCodeTag as ParserBBCodeTag 14 | from precise_bbcode.conf import settings as bbcode_settings 15 | from precise_bbcode.core.loading import get_subclasses 16 | from precise_bbcode.models import BBCodeTag 17 | from precise_bbcode.tag_pool import TagAlreadyCreated 18 | from precise_bbcode.tag_pool import TagAlreadyRegistered 19 | from precise_bbcode.tag_pool import TagNotRegistered 20 | from precise_bbcode.tag_pool import tag_pool 21 | 22 | 23 | class FooTag(ParserBBCodeTag): 24 | name = 'foo' 25 | 26 | class Options: 27 | render_embedded = False 28 | 29 | def render(self, value, option=None, parent=None): 30 | return '
    {}
    '.format(value) 31 | 32 | 33 | class FooTagAlt(ParserBBCodeTag): 34 | name = 'fooalt' 35 | 36 | class Options: 37 | render_embedded = False 38 | 39 | def render(self, value, option=None, parent=None): 40 | return '
    {}
    '.format(value) 41 | 42 | 43 | class FooTagSub(ParserBBCodeTag): 44 | name = 'foo2' 45 | 46 | class Options: 47 | render_embedded = False 48 | 49 | 50 | class BarTag(ParserBBCodeTag): 51 | name = 'bar' 52 | 53 | def render(self, value, option=None, parent=None): 54 | if not option: 55 | return '
    {}
    '.format(value) 56 | return '
    {}
    '.format(option, value) 57 | 58 | 59 | @pytest.mark.django_db 60 | class TestBbcodeTagPool(object): 61 | TAGS_TESTS = ( 62 | ('[fooalt]hello world![/fooalt]', '
    hello world!
    '), 63 | ('[bar]hello world![/bar]', '
    hello world!
    '), 64 | ('[fooalt]hello [bar]world![/bar][/fooalt]', '
    hello [bar]world![/bar]
    '), 65 | ( 66 | '[bar]hello [fooalt]world![/fooalt][/bar]', 67 | '
    hello
    world!
    ' 68 | ), 69 | ('[bar]안녕하세요![/bar]', '
    안녕하세요!
    '), 70 | ) 71 | 72 | def setup_method(self, method): 73 | self.parser = get_parser() 74 | 75 | def test_should_raise_if_a_tag_is_registered_twice(self): 76 | # Setup 77 | number_of_tags_before = len(tag_pool.get_tags()) 78 | tag_pool.register_tag(FooTag) 79 | # Run & check 80 | # Let's add it a second time. We should catch an exception 81 | with pytest.raises(TagAlreadyRegistered): 82 | tag_pool.register_tag(FooTag) 83 | # Let's make sure we have the same number of tags as before 84 | tag_pool.unregister_tag(FooTag) 85 | number_of_tags_after = len(tag_pool.get_tags()) 86 | assert number_of_tags_before == number_of_tags_after 87 | 88 | def test_cannot_register_tags_with_incorrect_parent_classes(self): 89 | # Setup 90 | number_of_tags_before = len(tag_pool.get_tags()) 91 | # Run & check 92 | with pytest.raises(ImproperlyConfigured): 93 | class ErrnoneousTag4: 94 | pass 95 | tag_pool.register_tag(ErrnoneousTag4) 96 | number_of_tags_after = len(tag_pool.get_tags()) 97 | assert number_of_tags_before == number_of_tags_after 98 | 99 | def test_cannot_register_tags_that_are_already_stored_in_the_database(self): 100 | # Setup 101 | BBCodeTag.objects.create( 102 | tag_definition='[tt]{TEXT}[/tt]', html_replacement='{TEXT}') 103 | # Run 104 | with pytest.raises(TagAlreadyCreated): 105 | class ErrnoneousTag9(ParserBBCodeTag): 106 | name = 'tt' 107 | definition_string = '[tt]{TEXT}[/tt]' 108 | format_string = '{TEXT}' 109 | tag_pool.register_tag(ErrnoneousTag9) 110 | 111 | def test_cannot_unregister_a_non_registered_tag(self): 112 | # Setup 113 | number_of_tags_before = len(tag_pool.get_tags()) 114 | # Run & check 115 | with pytest.raises(TagNotRegistered): 116 | tag_pool.unregister_tag(FooTagSub) 117 | number_of_tags_after = len(tag_pool.get_tags()) 118 | assert number_of_tags_before == number_of_tags_after 119 | 120 | def test_tags_can_be_rendered(self): 121 | # Setup 122 | parser_loader = BBCodeParserLoader(parser=self.parser) 123 | tag_pool.register_tag(FooTagAlt) 124 | tag_pool.register_tag(BarTag) 125 | parser_loader.init_bbcode_tags() 126 | # Run & check 127 | for bbcodes_text, expected_html_text in self.TAGS_TESTS: 128 | result = self.parser.render(bbcodes_text) 129 | assert result == expected_html_text 130 | 131 | def test_can_disable_builtin_tags(self): 132 | # Setup 133 | bbcode_settings.BBCODE_DISABLE_BUILTIN_TAGS = True 134 | parser_loader = BBCodeParserLoader(parser=BBCodeParser()) 135 | # Run & check 136 | parser_loader.load_parser() 137 | import precise_bbcode.bbcode.defaults.tag 138 | for tag_klass in get_subclasses(precise_bbcode.bbcode.defaults.tag, ParserBBCodeTag): 139 | assert tag_klass.name not in parser_loader.parser.bbcodes 140 | bbcode_settings.BBCODE_DISABLE_BUILTIN_TAGS = False 141 | 142 | 143 | @pytest.mark.django_db 144 | class TestBbcodeTag(object): 145 | def setup_method(self, method): 146 | self.parser = get_parser() 147 | 148 | def test_that_are_invalid_should_raise_at_runtime(self): 149 | # Run & check 150 | with pytest.raises(InvalidBBCodeTag): 151 | class ErrnoneousTag1(ParserBBCodeTag): 152 | pass 153 | with pytest.raises(InvalidBBCodeTag): 154 | class ErrnoneousTag2(ParserBBCodeTag): 155 | delattr(ParserBBCodeTag, 'name') 156 | with pytest.raises(InvalidBBCodeTag): 157 | class ErrnoneousTag3(ParserBBCodeTag): 158 | name = 'it\'s a bad tag name' 159 | with pytest.raises(InvalidBBCodeTag): 160 | class ErrnoneousTag4(ParserBBCodeTag): 161 | name = 'ooo' 162 | definition_string = '[ooo]{TEXT}[/ooo]' 163 | with pytest.raises(InvalidBBCodeTag): 164 | class ErrnoneousTag5(ParserBBCodeTag): 165 | name = 'ooo' 166 | definition_string = 'bad definition' 167 | format_string = 'bad format string' 168 | with pytest.raises(InvalidBBCodeTag): 169 | class ErrnoneousTag6(ParserBBCodeTag): 170 | name = 'ooo' 171 | definition_string = '[ooo]{TEXT}[/aaa]' 172 | format_string = 'bad format string' 173 | with pytest.raises(InvalidBBCodeTag): 174 | class ErrnoneousTag7(ParserBBCodeTag): 175 | name = 'ooo' 176 | definition_string = '[ooo]{TEXT}[/ooo]' 177 | format_string = '' 178 | with pytest.raises(InvalidBBCodeTag): 179 | class ErrnoneousTag8(ParserBBCodeTag): 180 | name = 'ooo' 181 | definition_string = '[ooo={TEXT}]{TEXT}[/ooo]' 182 | format_string = '{TEXT}' 183 | 184 | def test_containing_invalid_placeholders_should_raise_during_rendering(self): 185 | # Setup 186 | class TagWithInvalidPlaceholders(ParserBBCodeTag): 187 | name = 'bad' 188 | definition_string = '[bad]{FOOD}[/bad]' 189 | format_string = '{FOOD}' 190 | self.parser.add_bbcode_tag(TagWithInvalidPlaceholders) 191 | # Run 192 | with pytest.raises(InvalidBBCodePlaholder): 193 | self.parser.render('[bad]apple[/bad]') 194 | 195 | 196 | @pytest.mark.django_db 197 | class TestDbBbcodeTag(object): 198 | ERRONEOUS_TAGS_TESTS = ( 199 | {'tag_definition': '[tag]', 'html_replacement': ''}, 200 | {'tag_definition': 'it\'s not a tag', 'html_replacement': ''}, 201 | {'tag_definition': '[first]{TEXT1}[/end]', 'html_replacement': '

    {TEXT1}

    '}, 202 | {'tag_definition': '[t2y={TEXT1}]{TEXT1}[/t2y]', 'html_replacement': '{TEXT1}'}, 203 | { 204 | 'tag_definition': '[tag2]{TEXT1}[/tag2]', 205 | 'html_replacement': '

    {TEXT1}

    ', 206 | 'standalone': True 207 | }, 208 | {'tag_definition': '[start]{TEXT1}[/end]', 'html_replacement': '

    {TEXT1}

    '}, 209 | {'tag_definition': '[start]{TEXT1}[/end]', 'html_replacement': '

    {TEXT1}

    '}, 210 | {'tag_definition': '[start]{TEXT1}[/end]', 'html_replacement': '

    {TEXT2}

    '}, 211 | { 212 | 'tag_definition': '[start={TEXT1}]{TEXT1}[/end]', 213 | 'html_replacement': '

    {TEXT1}

    ' 214 | }, 215 | { 216 | 'tag_definition': '[justify]{TEXT1}[/justify]', 217 | 'html_replacement': '
    ' 218 | }, 219 | { 220 | 'tag_definition': '[center][/center]', 221 | 'html_replacement': '
    {TEXT1}
    ' 222 | }, 223 | { 224 | 'tag_definition': '[spe={COLOR}]{TEXT}[/spe]', 225 | 'html_replacement': '
    {TEXT}
    ' 226 | }, 227 | { 228 | 'tag_definition': '[spe]{TEXT}[/spe]', 229 | 'html_replacement': '
    {TEXT}
    ' 230 | }, 231 | {'tag_definition': '[spe]{UNKNOWN}[/spe]', 'html_replacement': '
    {UNKNOWN}
    '}, 232 | {'tag_definition': '[io]{TEXT#1}[/io]', 'html_replacement': '{TEXT#1}'}, 233 | {'tag_definition': '[io]{TEXTa}[/io]', 'html_replacement': '{TEXTb}'}, 234 | {'tag_definition': '[ test]{TEXT1}[/test]', 'html_replacement': '{TEXT}'}, 235 | {'tag_definition': '[test ]{TEXT1}[/test]', 'html_replacement': '{TEXT}'}, 236 | {'tag_definition': '[test]{TEXT1}[/ test ]', 'html_replacement': '{TEXT}'}, 237 | {'tag_definition': '[test]{TEXT1}[/test ]', 'html_replacement': '{TEXT}'}, 238 | {'tag_definition': '[foo]{TEXT1}[/foo ]', 'html_replacement': '{TEXT}'}, 239 | { 240 | 'tag_definition': '[bar]{TEXT}[/bar]', # Already registered 241 | 'html_replacement': '{TEXT}' 242 | }, 243 | ) 244 | 245 | VALID_TAG_TESTS = ( 246 | {'tag_definition': '[pre]{TEXT}[/pre]', 'html_replacement': '
    {TEXT}
    '}, 247 | { 248 | 'tag_definition': '[pre2={COLOR}]{TEXT1}[/pre2]', 249 | 'html_replacement': '
    {TEXT1}
    ' 250 | }, 251 | {'tag_definition': '[hrcustom]', 'html_replacement': '
    ', 'standalone': True}, 252 | { 253 | 'tag_definition': '[oo]{TEXT}', 254 | 'html_replacement': '
  • {TEXT}
  • ', 255 | 'same_tag_closes': True 256 | }, 257 | { 258 | 'tag_definition': '[h]{TEXT}[/h]', 259 | 'html_replacement': '{TEXT}', 260 | 'helpline': 'Display your text in bold' 261 | }, 262 | { 263 | 'tag_definition': '[hbold]{TEXT}[/hbold]', 264 | 'html_replacement': '{TEXT}', 265 | 'display_on_editor': False 266 | }, 267 | { 268 | 'tag_definition': '[pre3]{TEXT}[/pre3]', 269 | 'html_replacement': '
    {TEXT}
    ', 270 | 'newline_closes': True 271 | }, 272 | { 273 | 'tag_definition': '[pre4]{TEXT}[/pre4]', 274 | 'html_replacement': '
    {TEXT}
    ', 275 | 'same_tag_closes': True 276 | }, 277 | { 278 | 'tag_definition': '[troll]{TEXT}[/troll]', 279 | 'html_replacement': '
    {TEXT}
    ', 280 | 'end_tag_closes': True 281 | }, 282 | { 283 | 'tag_definition': '[troll1]{TEXT}[/troll1]', 284 | 'html_replacement': '
    {TEXT}
    ', 285 | 'transform_newlines': True 286 | }, 287 | { 288 | 'tag_definition': '[idea]{TEXT1}[/idea]', 289 | 'html_replacement': '
    {TEXT1}
    ', 290 | 'render_embedded': False 291 | }, 292 | { 293 | 'tag_definition': '[idea1]{TEXT1}[/idea1]', 294 | 'html_replacement': '
    {TEXT1}
    ', 295 | 'escape_html': False 296 | }, 297 | { 298 | 'tag_definition': '[link]{URL}[/link]', 299 | 'html_replacement': '
    {URL}
    ', 300 | 'replace_links': False 301 | }, 302 | { 303 | 'tag_definition': '[link1]{URL}[/link1]', 304 | 'html_replacement': '
    {URL}
    ', 305 | 'strip': True 306 | }, 307 | { 308 | 'tag_definition': '[mailto]{EMAIL}[/mailto]', 309 | 'html_replacement': '{EMAIL}', 310 | 'swallow_trailing_newline': True 311 | }, 312 | { 313 | 'tag_definition': '[food]{CHOICE=apple,tomato,orange}[/food]', 314 | 'html_replacement': '{CHOICE=apple,tomato,orange}' 315 | }, 316 | { 317 | 'tag_definition': '[food++={CHOICE2=red,blue}]{CHOICE1=apple,tomato,orange}[/food++]', 318 | 'html_replacement': ( 319 | '{CHOICE1=apple,tomato,orange}' 320 | ) 321 | }, 322 | { 323 | 'tag_definition': '[big]{RANGE=2,15}[/big]', 324 | 'html_replacement': '{RANGE=2,15}' 325 | }, 326 | { 327 | 'tag_definition': '[b]{TEXT}[/b]', # Default tag overriding 328 | 'html_replacement': '{TEXT}' 329 | }, 330 | ) 331 | 332 | def setup_method(self, method): 333 | self.parser = get_parser() 334 | 335 | def test_should_not_save_invalid_tags(self): 336 | # Run & check 337 | for tag_dict in self.ERRONEOUS_TAGS_TESTS: 338 | with pytest.raises(ValidationError): 339 | tag = BBCodeTag(**tag_dict) 340 | tag.clean() 341 | 342 | def test_should_save_valid_tags(self): 343 | # Run & check 344 | for tag_dict in self.VALID_TAG_TESTS: 345 | tag = BBCodeTag(**tag_dict) 346 | try: 347 | tag.clean() 348 | except ValidationError: 349 | self.fail('The following BBCode failed to validate: {}'.format(tag_dict)) 350 | 351 | def test_should_allow_tag_updates_if_the_name_does_not_change(self): 352 | # Setup 353 | tag_dict = {'tag_definition': '[pr]{TEXT}[/pr]', 'html_replacement': '
    {TEXT}
    '} 354 | tag = BBCodeTag(**tag_dict) 355 | tag.save() 356 | # Run 357 | tag.html_replacement = '{TEXT}' 358 | # Check 359 | try: 360 | tag.clean() 361 | except ValidationError: 362 | self.fail('The following BBCode failed to validate: {}'.format(tag_dict)) 363 | 364 | def test_should_allow_tag_creation_after_the_deletion_of_another_tag_with_the_same_name(self): 365 | # Setup 366 | tag_dict = {'tag_definition': '[pr]{TEXT}[/pr]', 'html_replacement': '
    {TEXT}
    '} 367 | tag = BBCodeTag(**tag_dict) 368 | tag.save() 369 | tag.delete() 370 | new_tag = BBCodeTag(**tag_dict) 371 | # Run & check 372 | try: 373 | new_tag.clean() 374 | except ValidationError: 375 | self.fail('The following BBCode failed to validate: {}'.format(tag_dict)) 376 | 377 | def test_should_allow_tag_creation_after_the_bulk_deletion_of_another_tag_with_the_same_name_in_the_admin(self): # noqa 378 | # Setup 379 | tag_dict = {'tag_definition': '[pr2]{TEXT}[/pr2]', 'html_replacement': '
    {TEXT}
    '} 380 | tag = BBCodeTag(**tag_dict) 381 | tag.save() 382 | 383 | admin_user = User.objects.create_user('admin', 'admin@admin.io', 'adminpass') 384 | admin_user.is_staff = True 385 | admin_user.is_superuser = True 386 | admin_user.save() 387 | client = Client() 388 | client.login(username='admin', password='adminpass') 389 | url = reverse('admin:precise_bbcode_bbcodetag_changelist') 390 | client.post(url, data={ 391 | 'action': 'delete_selected', '_selected_action': [tag.pk, ], 'post': 'yes'}) 392 | 393 | new_tag = BBCodeTag(**tag_dict) 394 | # Run & check 395 | try: 396 | new_tag.clean() 397 | except ValidationError: 398 | self.fail('The following BBCode failed to validate: {}'.format(tag_dict)) 399 | 400 | def test_should_save_default_bbcode_tags_rewrites(self): 401 | # Setup 402 | tag = BBCodeTag(tag_definition='[b]{TEXT1}[/b]', html_replacement='{TEXT1}') 403 | # Run & check 404 | try: 405 | tag.clean() 406 | except ValidationError: 407 | self.fail('The following BBCode failed to validate: {}'.format(tag)) 408 | 409 | def test_should_provide_the_required_parser_bbcode_tag_class(self): 410 | # Setup 411 | tag = BBCodeTag( 412 | **{'tag_definition': '[io]{TEXT}[/io]', 'html_replacement': '{TEXT}'} 413 | ) 414 | tag.save() 415 | # Run & check 416 | parser_tag_klass = tag.parser_tag_klass 417 | assert issubclass(parser_tag_klass, ParserBBCodeTag) 418 | assert parser_tag_klass.name == 'io' 419 | assert parser_tag_klass.definition_string == '[io]{TEXT}[/io]' 420 | assert parser_tag_klass.format_string == '{TEXT}' 421 | 422 | def test_can_be_rendered_by_the_bbcode_parser(self): 423 | # Setup 424 | parser_loader = BBCodeParserLoader(parser=self.parser) 425 | tag = BBCodeTag( 426 | **{ 427 | 'tag_definition': '[mail]{EMAIL}[/mail]', 428 | 'html_replacement': '{EMAIL}', 429 | 'swallow_trailing_newline': True 430 | } 431 | ) 432 | tag.save() 433 | parser_loader.init_custom_bbcode_tags() 434 | # Run & check 435 | assert ( 436 | self.parser.render('[mail]xyz@xyz.com[/mail]') == 437 | 'xyz@xyz.com' 438 | ) 439 | --------------------------------------------------------------------------------