├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── biome.json ├── django_prose_editor ├── .gitignore ├── __init__.py ├── apps.py ├── checks.py ├── config.py ├── fields.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ ├── django.po │ │ ├── djangojs.mo │ │ └── djangojs.po ├── sanitized.py ├── static │ └── django_prose_editor │ │ ├── configurable.js │ │ ├── default.js │ │ ├── editor.css │ │ ├── editor.css.map │ │ ├── editor.js │ │ ├── editor.js.map │ │ ├── material-icons.css │ │ ├── material-icons.woff2 │ │ ├── overrides.css │ │ └── overrides.css.map └── widgets.py ├── docs ├── Makefile ├── bundlers.rst ├── changelog.rst ├── conf.py ├── configuration.rst ├── custom_extensions.rst ├── development.rst ├── forms.rst ├── index.rst ├── installation.rst ├── make.bat ├── requirements.txt └── system_checks.rst ├── package.json ├── postcss.config.js ├── pyproject.toml ├── rslib.config.mjs ├── src ├── editor.css ├── editor.js ├── figure.js ├── fullscreen.js ├── history.js ├── horizontalRule.js ├── html.js ├── link.js ├── locale ├── menu.js ├── nospellcheck.js ├── orderedList.js ├── overrides.css ├── pm.js ├── table.js ├── typographic.js ├── utils.js └── utils │ └── canInsertNode.js ├── tests ├── conftest.py ├── manage.py └── testapp │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── settings.py │ ├── static │ └── testapp │ │ └── blue-bold.js │ ├── templates │ └── editor.html │ ├── test_checks.py │ ├── test_config.py │ ├── test_configurable.py │ ├── test_editor_destroy.py │ ├── test_form_field.py │ ├── test_prose_editor.py │ ├── test_prose_editor_e2e.py │ └── urls.py ├── tox.ini ├── update.sh └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | name: Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - '3.10' 18 | - '3.12' 19 | - '3.13' 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip tox pytest-playwright 30 | 31 | # Install playwright system dependencies 32 | - name: Install Playwright browsers 33 | run: | 34 | playwright install --with-deps chromium 35 | 36 | - name: Run tox targets for ${{ matrix.python-version }} 37 | run: | 38 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 39 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') python -m tox 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | \#*# 3 | /build 4 | /.bundle 5 | .coverage 6 | /data.db 7 | /dist 8 | .DS_Store 9 | /dump.rdb 10 | *.egg-info 11 | /.env 12 | /htdocs/e 13 | /.idea 14 | /log 15 | /media 16 | /node_modules 17 | *.pyc 18 | /static 19 | .*.sw* 20 | /tmp 21 | .tox 22 | /vendor 23 | /venv 24 | /.vscode 25 | yarn-error.log 26 | build 27 | test-results 28 | django_prose_editor/static/django_prose_editor/overrides.js 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$|django_prose_editor/editor.*|django_prose_editor/overrides.*" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-builtin-literals 8 | - id: check-executables-have-shebangs 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | - repo: https://github.com/adamchainz/django-upgrade 17 | rev: 1.25.0 18 | hooks: 19 | - id: django-upgrade 20 | args: [--target-version, "4.2"] 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.11.11" 23 | hooks: 24 | - id: ruff-check 25 | args: [--unsafe-fixes] 26 | - id: ruff-format 27 | - repo: https://github.com/biomejs/pre-commit 28 | rev: "v2.0.0-beta.5" 29 | hooks: 30 | - id: biome-check 31 | args: [--unsafe] 32 | exclude: "static" 33 | - repo: https://github.com/tox-dev/pyproject-fmt 34 | rev: v2.6.0 35 | hooks: 36 | - id: pyproject-fmt 37 | - repo: https://github.com/abravalheri/validate-pyproject 38 | rev: v0.24.1 39 | hooks: 40 | - id: validate-pyproject 41 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-24.04 8 | tools: 9 | python: "3.11" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change log 2 | ========== 3 | 4 | Next version 5 | ~~~~~~~~~~~~ 6 | 7 | - Switched from `esbuild `__ to 8 | `rslib `__. Bundles are smaller and I'm a heavy 9 | user of rspack anyway. 10 | 11 | 12 | 0.12 (2025-05-12) 13 | ~~~~~~~~~~~~~~~~~ 14 | 15 | - Updated the Tiptap version to the 3.0 beta to avoid problems with extensions 16 | sharing storage over multiple editor instances. 17 | - Fixed the menu to not run commands on click when the command is disabled. 18 | - Changed the ``addLink`` command to not do anything if the selection is empty 19 | or if the selection isn't inside a link mark currently. 20 | - Fixed the title attribute functionality of link marks. Titles have been 21 | inadvertently broken since 0.10 because I missed the fact that the Tiptap 22 | link extension doesn't define the attribute in the schema. 23 | - Changed the ordered list menu button to disable itself when an ordered list 24 | cannot be inserted. 25 | - Updated the figure menu button to actually check whether figures can be 26 | inserted or not. Same for the horizontal rule menu button. 27 | - Added styles to selected nodes so that e.g. selected horizontal rules are 28 | shown as such. 29 | - Started including source maps again. 30 | - Convert textareas to use autogrow. 31 | - Changed the prose editor dialog to use ``div.prose-editor-dialog-field`` 32 | elements to wrap inputs and their labels instead of paragraphs. 33 | - Allowed callable default values in the ``updateAttrsDialog``. 34 | 35 | 36 | 0.11 (2025-04-16) 37 | ~~~~~~~~~~~~~~~~~ 38 | 39 | - Added a new way of configuring the ``ProseEditorField`` by using the 40 | ``extensions`` argument. This allows specifying Tiptap extensions to use and 41 | also optionally allows configuring them. nh3 sanitization rules are 42 | automatically derived from the extension configuration when using 43 | sanitization. A system check warning is emitted if you're using this 44 | mechanism but haven't opted into sanitization. 45 | - Using the ``ProseEditorField`` without the ``extensions`` parameter has been 46 | deprecated, and a system check warning has been added for automatically 47 | detecting this. 48 | - Added support for specifying editor extensions using the 49 | ``DJANGO_PROSE_EDITOR_EXTENSIONS`` setting, which allows transparently adding 50 | JavaScript modules to the editor without having to write your own preset. 51 | Writing presets is and will be supported for even more advanced use cases, 52 | but the extensions mechanism hopefully covers 99% of all use cases. 53 | - Switched the JavaScript to use ES modules and importmaps. If you've been 54 | using 0.10 you have to update your code to use ES modules (`` 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/testapp/test_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, Warning 2 | from django.test import SimpleTestCase, override_settings 3 | 4 | from django_prose_editor.checks import ( 5 | check_extensions_parameter, 6 | check_js_preset_configuration, 7 | check_sanitization_enabled, 8 | ) 9 | 10 | 11 | class ChecksTests(SimpleTestCase): 12 | @override_settings(DJANGO_PROSE_EDITOR_PRESETS={}) 13 | def test_no_default_preset_override(self): 14 | """Test that no errors are returned when 'default' preset is not overridden.""" 15 | errors = check_js_preset_configuration(None) 16 | assert errors == [] 17 | 18 | @override_settings(DJANGO_PROSE_EDITOR_PRESETS={"default": []}) 19 | def test_default_preset_override(self): 20 | """Test that an error is returned when 'default' preset is overridden.""" 21 | errors = check_js_preset_configuration(None) 22 | assert len(errors) == 1 23 | assert isinstance(errors[0], Error) 24 | assert ( 25 | errors[0].msg 26 | == 'Overriding the "default" preset in DJANGO_PROSE_EDITOR_PRESETS is not allowed.' 27 | ) 28 | assert errors[0].id == "django_prose_editor.E001" 29 | 30 | def test_config_deprecation_system_check(self): 31 | """Test that using the 'config' parameter is caught by system checks.""" 32 | # Run the check function 33 | warnings = check_extensions_parameter([]) 34 | 35 | # We expect warnings for each model that uses legacy config 36 | expected_models = [ 37 | "ProseEditorModel", 38 | "SanitizedProseEditorModel", 39 | "TableProseEditorModel", 40 | ] 41 | 42 | # Check that we have at least the expected number of warnings 43 | assert len(warnings) >= len(expected_models), ( 44 | f"Expected at least {len(expected_models)} warnings, got {len(warnings)}" 45 | ) 46 | 47 | # For each expected model, make sure there's a corresponding warning 48 | for model_name in expected_models: 49 | model_warnings = [w for w in warnings if w.obj and model_name in w.obj] 50 | assert len(model_warnings) > 0, ( 51 | f"No deprecation warning found for {model_name}" 52 | ) 53 | 54 | # Verify the warning properties for one of them 55 | if model_name == "TableProseEditorModel": 56 | warning = model_warnings[0] 57 | assert isinstance(warning, Warning) 58 | assert warning.id == "django_prose_editor.W001" 59 | assert "legacy configuration format" in warning.msg 60 | # self.assertIn("extensions", warning.hint) 61 | 62 | def test_sanitization_check(self): 63 | """Test that all ProseEditorField instances without sanitization are caught.""" 64 | # Run the check function on existing models 65 | warnings = check_sanitization_enabled([]) 66 | 67 | # We expect warnings for ProseEditorModel since it doesn't have sanitization 68 | # but not for SanitizedProseEditorModel (has sanitization) or ConfigurableProseEditorModel (has sanitize=True) 69 | warning_objects = [w.obj for w in warnings] 70 | 71 | # Check expected warnings 72 | expected_warnings = any( 73 | "ProseEditorModel.description" in obj for obj in warning_objects 74 | ) 75 | assert expected_warnings, "No warning for ProseEditorModel without sanitization" 76 | 77 | # Check unexpected warnings 78 | assert not any("SanitizedProseEditorModel" in obj for obj in warning_objects), ( 79 | "Unexpected warning for SanitizedProseEditorModel which should have sanitization" 80 | ) 81 | assert not any( 82 | "ConfigurableProseEditorModel" in obj for obj in warning_objects 83 | ), "Unexpected warning for ConfigurableProseEditorModel which has sanitize=True" 84 | 85 | # Test with different field configurations using a synthetic model 86 | from django.db import models 87 | 88 | from django_prose_editor.fields import ProseEditorField 89 | 90 | class TestModel(models.Model): 91 | class Meta: 92 | app_label = "test_app_never_installed" 93 | 94 | def __str__(self): 95 | return "" 96 | 97 | # Fields with different configurations, all without sanitization 98 | with_extensions = ProseEditorField( 99 | config={"extensions": {"Bold": True}}, sanitize=False 100 | ) 101 | 102 | legacy_config = ProseEditorField(config={"types": ["Bold"]}, sanitize=False) 103 | 104 | no_config = ProseEditorField(sanitize=False) 105 | 106 | # Manually add our test model to the models to check 107 | warnings = check_sanitization_enabled( 108 | [type("AppConfig", (), {"get_models": lambda: [TestModel]})] 109 | ) 110 | 111 | # Check that we got warnings for all three fields 112 | assert len(warnings) == 3 113 | 114 | # Check extension field warning 115 | extension_warnings = [ 116 | w 117 | for w in warnings 118 | if "test_app_never_installed.TestModel.with_extensions" in w.obj 119 | ] 120 | assert len(extension_warnings) == 1 121 | assert "using extensions without sanitization" in extension_warnings[0].msg 122 | assert "matches your configured extensions" in extension_warnings[0].hint 123 | 124 | # Check legacy config field warning 125 | legacy_warnings = [ 126 | w 127 | for w in warnings 128 | if "test_app_never_installed.TestModel.legacy_config" in w.obj 129 | ] 130 | assert len(legacy_warnings) == 1 131 | assert "doesn't have sanitization enabled" in legacy_warnings[0].msg 132 | assert "extensions mechanism with sanitize=True" in legacy_warnings[0].hint 133 | 134 | # Check no config field warning 135 | no_config_warnings = [ 136 | w 137 | for w in warnings 138 | if "test_app_never_installed.TestModel.no_config" in w.obj 139 | ] 140 | assert len(no_config_warnings) == 1 141 | assert "doesn't have sanitization enabled" in no_config_warnings[0].msg 142 | assert "extensions mechanism with sanitize=True" in no_config_warnings[0].hint 143 | -------------------------------------------------------------------------------- /tests/testapp/test_config.py: -------------------------------------------------------------------------------- 1 | """Tests for the configuration module.""" 2 | 3 | from django.test import TestCase, override_settings 4 | from js_asset import static_lazy 5 | 6 | from django_prose_editor.config import ( 7 | allowlist_from_extensions, 8 | expand_extensions, 9 | html_tags, 10 | js_from_extensions, 11 | ) 12 | 13 | 14 | class ConfigFunctionsTestCase(TestCase): 15 | """Tests for the configuration helper functions.""" 16 | 17 | def test_allowlist_from_extensions_basic(self): 18 | """Test that allowlist_from_extensions generates the correct HTML allowlist.""" 19 | extensions = { 20 | "Bold": True, 21 | "Italic": True, 22 | "Link": True, 23 | } 24 | 25 | allowlist = allowlist_from_extensions(extensions) 26 | 27 | # Check that the core tags are included 28 | assert "tags" in allowlist 29 | assert "attributes" in allowlist 30 | 31 | # Check that the specific tags are included 32 | assert "strong" in allowlist["tags"] # Bold 33 | assert "em" in allowlist["tags"] # Italic 34 | assert "a" in allowlist["tags"] # Link 35 | 36 | # Check that link attributes are included 37 | assert "a" in allowlist["attributes"] 38 | assert "href" in allowlist["attributes"]["a"] 39 | assert "rel" in allowlist["attributes"]["a"] 40 | assert "title" in allowlist["attributes"]["a"] 41 | 42 | # Verify that js_modules is not included in the allowlist 43 | assert "js_modules" not in allowlist 44 | 45 | def test_allowlist_from_extensions_complex(self): 46 | """Test allowlist_from_extensions with more complex extension configurations.""" 47 | extensions = { 48 | "Heading": {"levels": [1, 2]}, 49 | "CodeBlock": {"languageClassPrefix": "language-"}, 50 | "Link": {"enableTarget": True, "protocols": ["https", "mailto"]}, 51 | } 52 | 53 | allowlist = allowlist_from_extensions(extensions) 54 | 55 | # Check heading levels are limited to h1, h2 56 | assert "h1" in allowlist["tags"] 57 | assert "h2" in allowlist["tags"] 58 | assert "h3" not in allowlist["tags"] 59 | 60 | # Check code block with language classes 61 | assert "pre" in allowlist["tags"] 62 | assert "code" in allowlist["tags"] 63 | assert "pre" in allowlist["attributes"] 64 | assert "class" in allowlist["attributes"]["pre"] 65 | 66 | # Check link with target and protocols 67 | assert "a" in allowlist["tags"] 68 | assert "target" in allowlist["attributes"]["a"] 69 | assert "url_schemes" in allowlist 70 | assert "https" in allowlist["url_schemes"] 71 | assert "mailto" in allowlist["url_schemes"] 72 | 73 | @override_settings( 74 | DJANGO_PROSE_EDITOR_EXTENSIONS=[ 75 | { 76 | "js": [static_lazy("testapp/blue-bold.js")], 77 | "extensions": { 78 | "CustomExt": html_tags(tags=["div"], attributes={"div": ["class"]}) 79 | }, 80 | }, 81 | { 82 | "js": [ 83 | static_lazy("testapp/other.js"), 84 | static_lazy("testapp/another.js"), 85 | ], 86 | "extensions": { 87 | "OtherExt": html_tags( 88 | tags=["span"], attributes={"span": ["data-test"]} 89 | ) 90 | }, 91 | }, 92 | ] 93 | ) 94 | def test_js_from_extensions(self): 95 | """Test that js_from_extensions returns the correct JS modules.""" 96 | extensions = { 97 | "Bold": True, 98 | "Italic": True, 99 | "CustomExt": True, 100 | "OtherExt": True, 101 | } 102 | 103 | js_modules = js_from_extensions(extensions) 104 | 105 | # Should be a list of JS modules 106 | assert isinstance(js_modules, list) 107 | 108 | # Should contain the custom extensions' JS modules 109 | assert static_lazy("testapp/blue-bold.js") in js_modules 110 | assert static_lazy("testapp/other.js") in js_modules 111 | assert static_lazy("testapp/another.js") in js_modules 112 | 113 | # Test with dependencies 114 | extensions = { 115 | "Table": True, # This should automatically include TableRow, TableCell, etc. 116 | } 117 | 118 | js_modules = js_from_extensions(extensions) 119 | 120 | # Dependencies don't have their own JS modules, so this should be an empty list 121 | assert js_modules == [] 122 | 123 | def test_js_from_extensions_with_invalid_extension(self): 124 | """Test that js_from_extensions handles invalid extensions gracefully.""" 125 | extensions = { 126 | "NonExistentExtension": True, 127 | } 128 | 129 | js_modules = js_from_extensions(extensions) 130 | 131 | # Should still return a list, just empty 132 | assert isinstance(js_modules, list) 133 | assert js_modules == [] 134 | 135 | def test_expand_extensions_with_dependencies(self): 136 | """Test that expand_extensions correctly adds dependent extensions.""" 137 | extensions = { 138 | "Bold": True, 139 | "Table": True, 140 | "Figure": True, 141 | } 142 | 143 | expanded = expand_extensions(extensions) 144 | 145 | # Original extensions should be preserved 146 | assert "Bold" in expanded 147 | assert "Table" in expanded 148 | assert "Figure" in expanded 149 | 150 | # Dependencies should be added 151 | assert "TableRow" in expanded 152 | assert "TableHeader" in expanded 153 | assert "TableCell" in expanded 154 | assert "Caption" in expanded 155 | assert "Image" in expanded 156 | 157 | # Core extensions should always be included 158 | assert "Document" in expanded 159 | assert "Paragraph" in expanded 160 | assert "Text" in expanded 161 | 162 | def test_disabled_extensions(self): 163 | """Test that disabled extensions are properly handled.""" 164 | extensions = { 165 | "Bold": True, 166 | "Italic": False, # Explicitly disabled 167 | "Table": True, 168 | } 169 | 170 | expanded = expand_extensions(extensions) 171 | 172 | # Enabled extensions should be present 173 | assert "Bold" in expanded 174 | assert "Table" in expanded 175 | 176 | # Disabled extensions should be removed 177 | assert "Italic" not in expanded 178 | 179 | # Check that disabled extensions don't affect the allowlist 180 | allowlist = allowlist_from_extensions(extensions) 181 | assert "strong" in allowlist["tags"] # Bold 182 | assert "em" not in allowlist["tags"] # Italic should be excluded 183 | -------------------------------------------------------------------------------- /tests/testapp/test_configurable.py: -------------------------------------------------------------------------------- 1 | """Tests for the new configurable prose editor field.""" 2 | 3 | import json 4 | 5 | from django.forms.models import ModelForm 6 | from django.test import TestCase 7 | 8 | from testapp.models import ConfigurableProseEditorModel 9 | 10 | 11 | class ConfigurableFormTestCase(TestCase): 12 | """Tests for the configurable field form rendering.""" 13 | 14 | def test_dependencies_expanded(self): 15 | """Test that all dependencies are properly expanded in raw_extensions.""" 16 | 17 | class TestForm(ModelForm): 18 | class Meta: 19 | model = ConfigurableProseEditorModel 20 | fields = ["description"] 21 | 22 | form = TestForm() 23 | widget = form.fields["description"].widget 24 | 25 | context = widget.get_context("description", "", {}) 26 | config = json.loads( 27 | context["widget"]["attrs"]["data-django-prose-editor-configurable"] 28 | ) 29 | 30 | # The original extensions only had Bold, Italic, and Table 31 | assert "Bold" in config["extensions"] 32 | assert "Italic" in config["extensions"] 33 | assert "Table" in config["extensions"] 34 | 35 | # The custom BlueBold extension should also be included 36 | assert "BlueBold" in config["extensions"] 37 | 38 | # The following should be included as dependencies 39 | assert "TableRow" in config["extensions"] 40 | assert "TableHeader" in config["extensions"] 41 | assert "TableCell" in config["extensions"] 42 | 43 | # Core extensions should always be included 44 | assert "Paragraph" in config["extensions"] 45 | assert "Document" in config["extensions"] 46 | assert "Text" in config["extensions"] 47 | 48 | def test_heading_levels_config(self): 49 | """Test that heading levels are properly passed to the widget.""" 50 | 51 | class TestForm(ModelForm): 52 | class Meta: 53 | model = ConfigurableProseEditorModel 54 | fields = ["description"] 55 | 56 | form = TestForm() 57 | widget = form.fields["description"].widget 58 | 59 | # Get the expanded extensions from the widget attributes 60 | 61 | context = widget.get_context("description", "", {}) 62 | config = json.loads( 63 | context["widget"]["attrs"]["data-django-prose-editor-configurable"] 64 | ) 65 | 66 | # Check that Heading is in extensions with the proper configuration 67 | assert "Heading" in config["extensions"] 68 | assert config["extensions"]["Heading"]["levels"] == [1, 2, 3] 69 | 70 | assert config["extensions"] == widget.config["extensions"] | { 71 | "Document": True, 72 | "Dropcursor": True, 73 | "Gapcursor": True, 74 | "Paragraph": True, 75 | "Text": True, 76 | "Menu": True, 77 | "NoSpellCheck": True, 78 | # Enable history by default unless explicitly disabled 79 | "History": True, 80 | "TableRow": True, 81 | "TableCell": True, 82 | "TableHeader": True, 83 | } 84 | 85 | def test_sanitization_works(self): 86 | """Test that basic sanitization works correctly with ConfigurableProseEditorField.""" 87 | 88 | # Create a form instance 89 | class TestForm(ModelForm): 90 | class Meta: 91 | model = ConfigurableProseEditorModel 92 | fields = ["description"] 93 | 94 | # Test that script tags are removed 95 | html = "

Text with

" 96 | form = TestForm(data={"description": html}) 97 | assert form.is_valid() 98 | 99 | # Create and save the model instance to trigger sanitization 100 | instance = form.save() 101 | sanitized = instance.description 102 | assert "

Text with

" == sanitized, "Script tags should be removed" 103 | 104 | # Test that basic formatting is preserved 105 | html = "

Bold and italic text

" 106 | form = TestForm(data={"description": html}) 107 | assert form.is_valid() 108 | instance = form.save() 109 | sanitized = instance.description 110 | assert "

Bold and italic text

" == sanitized, ( 111 | "Standard formatting should be preserved" 112 | ) 113 | 114 | # Test that table elements are preserved 115 | html = "
Header
Cell
" 116 | form = TestForm(data={"description": html}) 117 | assert form.is_valid() 118 | instance = form.save() 119 | sanitized = instance.description 120 | assert ( 121 | "
Header
Cell
" 122 | == sanitized 123 | ), "Table elements should be preserved with proper structure" 124 | 125 | # Heading levels are configured as [1, 2, 3] in the model 126 | # Check that h1, h2, h3 are preserved but h4 is filtered out 127 | html = "

H1

H2

H3

H4

" 128 | form = TestForm(data={"description": html}) 129 | assert form.is_valid() 130 | instance = form.save() 131 | instance.full_clean() 132 | sanitized = instance.description 133 | 134 | # Check that h1, h2, h3 tags are preserved 135 | assert "

" in sanitized, "h1 should be preserved" 136 | assert "

" in sanitized, "h2 should be preserved" 137 | assert "

" in sanitized, "h3 should be preserved" 138 | 139 | # h4 should be removed since it's not in the allowed levels [1, 2, 3] 140 | assert "

" not in sanitized, "h4 should be removed" 141 | # But the content should still be there 142 | assert "H4" in sanitized, "content from h4 should still exist" 143 | 144 | # Test that unsupported tags (sub, sup) are removed 145 | html = "

Normal text with subscript and superscript

" 146 | form = TestForm(data={"description": html}) 147 | assert form.is_valid() 148 | instance = form.save() 149 | sanitized = instance.description 150 | assert "" not in sanitized, "sub tags should be removed" 151 | assert "" not in sanitized, "sup tags should be removed" 152 | assert "subscript" in sanitized, "content from sub should still exist" 153 | assert "superscript" in sanitized, "content from sup should still exist" 154 | 155 | # Test link sanitization - Link is not in the extensions list, 156 | # so it should be removed 157 | html = '

Link with attributes

' 158 | form = TestForm(data={"description": html}) 159 | assert form.is_valid() 160 | instance = form.save() 161 | sanitized = instance.description 162 | # Link tag should be removed since it's not in the extensions 163 | assert " { 39 | const textarea = document.getElementById('test-textarea'); 40 | const editorDiv = document.querySelector('.prose-editor'); 41 | return textarea && editorDiv && textarea.closest('.prose-editor') === editorDiv; 42 | } 43 | """) 44 | assert textarea_in_editor, "Textarea should be inside editor container" 45 | 46 | # Now call destroyEditor to test the destruction 47 | destroy_result = page.evaluate("window.destroyEditor()") 48 | assert destroy_result, "Editor destruction should succeed" 49 | 50 | # Check that the editor container is gone 51 | expect(page.locator(".prose-editor")).to_have_count(0) 52 | 53 | # Check that the textarea still exists and is back in the test-container 54 | expect(page.locator("#test-textarea")).to_have_count(1) 55 | 56 | # Check that the textarea is back in its original container 57 | textarea_in_container = page.evaluate(""" 58 | () => { 59 | const textarea = document.getElementById('test-textarea'); 60 | const container = document.getElementById('test-container'); 61 | return textarea.parentElement === container; 62 | } 63 | """) 64 | assert textarea_in_container, "Textarea should be back in original container" 65 | -------------------------------------------------------------------------------- /tests/testapp/test_form_field.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.admin import widgets 3 | from django.test import TestCase 4 | 5 | from django_prose_editor.fields import ProseEditorFormField, _is 6 | from django_prose_editor.widgets import AdminProseEditorWidget, ProseEditorWidget 7 | 8 | 9 | class CustomWidget(forms.Textarea): 10 | """A custom widget that is not a ProseEditorWidget.""" 11 | 12 | 13 | class IsHelperFunctionTest(TestCase): 14 | def test_is_function(self): 15 | """Test the _is helper function for widget type checking.""" 16 | # Test with classes 17 | assert _is(ProseEditorWidget, ProseEditorWidget) 18 | assert _is(CustomWidget, forms.Textarea) 19 | assert not _is(CustomWidget, ProseEditorWidget) 20 | 21 | # Test with instances 22 | assert _is(ProseEditorWidget(), ProseEditorWidget) 23 | assert _is(CustomWidget(), forms.Textarea) 24 | assert not _is(ProseEditorWidget(), widgets.AdminTextareaWidget) 25 | 26 | 27 | class ProseEditorFormFieldTest(TestCase): 28 | def test_widget_handling(self): 29 | """Test the widget handling in ProseEditorFormField.""" 30 | # Test when widget is None (default case, line 80 branch) 31 | field = ProseEditorFormField() 32 | assert isinstance(field.widget, ProseEditorWidget) 33 | 34 | # Test when widget is a class that is not a ProseEditorWidget (line 81 branch) 35 | field = ProseEditorFormField(widget=CustomWidget) 36 | assert isinstance(field.widget, ProseEditorWidget) 37 | 38 | # Test when widget is an instance that is not a ProseEditorWidget (line 81 branch) 39 | field = ProseEditorFormField(widget=CustomWidget()) 40 | assert isinstance(field.widget, ProseEditorWidget) 41 | 42 | # Test with AdminTextareaWidget class (line 79 branch) 43 | field = ProseEditorFormField(widget=widgets.AdminTextareaWidget) 44 | assert isinstance(field.widget, AdminProseEditorWidget) 45 | 46 | # Test with AdminTextareaWidget instance (line 79 branch) 47 | field = ProseEditorFormField(widget=widgets.AdminTextareaWidget()) 48 | assert isinstance(field.widget, AdminProseEditorWidget) 49 | 50 | # For completeness, also test with a ProseEditorWidget class and instance 51 | field = ProseEditorFormField(widget=ProseEditorWidget) 52 | assert isinstance(field.widget, ProseEditorWidget) 53 | 54 | field = ProseEditorFormField(widget=ProseEditorWidget()) 55 | assert isinstance(field.widget, ProseEditorWidget) 56 | 57 | def test_cleaning(self): 58 | class Form(forms.Form): 59 | content = ProseEditorFormField(sanitize=lambda html: "Hello") 60 | 61 | form = Form({"content": "World"}) 62 | assert form.is_valid() 63 | assert form.cleaned_data == {"content": "Hello"} 64 | 65 | def test_form_field_extensions(self): 66 | class Form(forms.Form): 67 | content = ProseEditorFormField( 68 | config={"extensions": {"Bold": True}}, sanitize=True 69 | ) 70 | 71 | form = Form({"content": "Hello World"}) 72 | assert form.is_valid() 73 | assert form.cleaned_data == {"content": "Hello World"} 74 | 75 | print(form.fields["content"].config) 76 | print(form.fields["content"].preset) 77 | 78 | assert "Bold" in form.fields["content"].config["extensions"] 79 | assert form.fields["content"].preset == "configurable" 80 | 81 | 82 | class FormWithProseEditorField(forms.Form): 83 | """A form using ProseEditorFormField with different widget configurations.""" 84 | 85 | # Default widget (None) 86 | content_default = ProseEditorFormField() 87 | 88 | # Non-ProseEditorWidget class 89 | content_custom_class = ProseEditorFormField(widget=CustomWidget) 90 | 91 | # Non-ProseEditorWidget instance 92 | content_custom_instance = ProseEditorFormField(widget=CustomWidget()) 93 | 94 | # AdminTextareaWidget class 95 | content_admin_class = ProseEditorFormField(widget=widgets.AdminTextareaWidget) 96 | 97 | # AdminTextareaWidget instance 98 | content_admin_instance = ProseEditorFormField(widget=widgets.AdminTextareaWidget()) 99 | 100 | 101 | class FormRenderingTest(TestCase): 102 | def test_form_rendering(self): 103 | """Test that forms with different widget configurations render correctly.""" 104 | form = FormWithProseEditorField() 105 | 106 | # The form should render all fields with appropriate ProseEditor widgets 107 | html = form.as_p() 108 | 109 | # Count the number of data-django-prose-editor attributes 110 | # All fields should use the data attribute with default preset 111 | assert html.count("data-django-prose-editor-default") == 5 112 | 113 | # All fields should use ProseEditor widgets (either standard or admin) 114 | total_prose_editors = len( 115 | [ 116 | field 117 | for field in form.fields.values() 118 | if isinstance(field.widget, ProseEditorWidget | AdminProseEditorWidget) 119 | ] 120 | ) 121 | assert total_prose_editors == 5 # All 5 fields should use ProseEditor widgets 122 | -------------------------------------------------------------------------------- /tests/testapp/test_prose_editor.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django.contrib.auth.models import User 3 | from django.test import Client 4 | 5 | from testapp.models import ( 6 | ProseEditorModel, 7 | SanitizedProseEditorModel, 8 | ) 9 | 10 | 11 | class Test(test.TestCase): 12 | def test_standard_field(self): 13 | m = ProseEditorModel(description="

") 14 | m.full_clean() 15 | assert m.description == "" 16 | 17 | m = ProseEditorModel(description="

") 18 | m.full_clean() 19 | assert m.description == "" 20 | 21 | m = ProseEditorModel(description="

hello

") 22 | m.full_clean() 23 | assert m.description == "

hello

" 24 | 25 | def test_sanitized_field(self): 26 | m = SanitizedProseEditorModel( 27 | description="

Hello

" 28 | ) 29 | m.full_clean() 30 | assert m.description == "

Hello

" 31 | 32 | m = SanitizedProseEditorModel(description="

") 33 | m.full_clean() 34 | assert m.description == "" 35 | 36 | m = SanitizedProseEditorModel(description="

") 37 | m.full_clean() 38 | assert m.description == "" 39 | 40 | m = SanitizedProseEditorModel(description="

hello

") 41 | m.full_clean() 42 | assert m.description == "

hello

" 43 | 44 | def test_admin(self): 45 | client = Client() 46 | client.force_login( 47 | User.objects.create_superuser("admin", "admin@example.com", "password") 48 | ) 49 | 50 | response = client.get("/admin/testapp/proseeditormodel/add/") 51 | # print(response, response.content.decode("utf-8")) 52 | self.assertContains( 53 | response, 'href="/static/django_prose_editor/overrides.css"' 54 | ) 55 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.shortcuts import render 3 | from django.urls import path 4 | 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("editor/", lambda request: render(request, "editor.html")), 9 | ] 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310,311,312}-dj{42} 4 | py{312,313}-dj{51,52,main} 5 | 6 | [testenv] 7 | usedevelop = true 8 | extras = all,tests 9 | passenv = 10 | HOME 11 | PYTHONPATH 12 | DISPLAY 13 | XAUTHORITY 14 | commands = 15 | playwright install chromium 16 | pytest --cov=django_prose_editor --cov-report=term-missing --browser chromium tests/testapp {posargs} 17 | deps = 18 | dj42: Django>=4.2,<5.0 19 | dj51: Django>=5.1,<5.2 20 | dj52: Django>=5.2,<6.0 21 | djmain: https://github.com/django/django/archive/main.tar.gz 22 | 23 | # The default testenv now includes Playwright 24 | 25 | [testenv:docs] 26 | deps = 27 | -r docs/requirements.txt 28 | changedir = docs 29 | commands = make html 30 | skip_install = true 31 | allowlist_externals = make 32 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | yarn upgrade \ 3 | autoprefixer postcss @rslib/core \ 4 | @tiptap/core@next \ 5 | @tiptap/extension-blockquote@next \ 6 | @tiptap/extension-bold@next \ 7 | @tiptap/extension-code-block@next \ 8 | @tiptap/extension-code@next \ 9 | @tiptap/extension-color@next \ 10 | @tiptap/extension-document@next \ 11 | @tiptap/extension-hard-break@next \ 12 | @tiptap/extension-heading@next \ 13 | @tiptap/extension-highlight@next \ 14 | @tiptap/extension-horizontal-rule@next \ 15 | @tiptap/extension-image@next \ 16 | @tiptap/extension-italic@next \ 17 | @tiptap/extension-link@next \ 18 | @tiptap/extension-list@next \ 19 | @tiptap/extension-paragraph@next \ 20 | @tiptap/extension-strike@next \ 21 | @tiptap/extension-subscript@next \ 22 | @tiptap/extension-superscript@next \ 23 | @tiptap/extension-table@next \ 24 | @tiptap/extension-text-align@next \ 25 | @tiptap/extension-text-style@next \ 26 | @tiptap/extension-text@next \ 27 | @tiptap/extension-underline@next \ 28 | @tiptap/extensions@next \ 29 | @tiptap/pm@next 30 | --------------------------------------------------------------------------------