django-richtext-link 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. django_richtext_link-0.3.0/.gitignore +61 -0
  2. django_richtext_link-0.3.0/AUTHORS +7 -0
  3. django_richtext_link-0.3.0/LICENSE +28 -0
  4. django_richtext_link-0.3.0/PKG-INFO +571 -0
  5. django_richtext_link-0.3.0/README.md +524 -0
  6. django_richtext_link-0.3.0/pyproject.toml +118 -0
  7. django_richtext_link-0.3.0/richtext_link/__init__.py +1 -0
  8. django_richtext_link-0.3.0/richtext_link/admin.py +133 -0
  9. django_richtext_link-0.3.0/richtext_link/apps.py +11 -0
  10. django_richtext_link-0.3.0/richtext_link/checks.py +231 -0
  11. django_richtext_link-0.3.0/richtext_link/conf.py +74 -0
  12. django_richtext_link-0.3.0/richtext_link/contrib/__init__.py +0 -0
  13. django_richtext_link-0.3.0/richtext_link/contrib/djangocms_text/__init__.py +103 -0
  14. django_richtext_link-0.3.0/richtext_link/fields.py +32 -0
  15. django_richtext_link-0.3.0/richtext_link/guards.py +70 -0
  16. django_richtext_link-0.3.0/richtext_link/link_index/__init__.py +0 -0
  17. django_richtext_link-0.3.0/richtext_link/link_index/admin.py +37 -0
  18. django_richtext_link-0.3.0/richtext_link/link_index/apps.py +43 -0
  19. django_richtext_link-0.3.0/richtext_link/link_index/conf.py +10 -0
  20. django_richtext_link-0.3.0/richtext_link/link_index/index.py +150 -0
  21. django_richtext_link-0.3.0/richtext_link/link_index/management/__init__.py +0 -0
  22. django_richtext_link-0.3.0/richtext_link/link_index/management/commands/__init__.py +0 -0
  23. django_richtext_link-0.3.0/richtext_link/link_index/management/commands/rebuild_richtext_link_index.py +20 -0
  24. django_richtext_link-0.3.0/richtext_link/link_index/migrations/0001_initial.py +70 -0
  25. django_richtext_link-0.3.0/richtext_link/link_index/migrations/__init__.py +0 -0
  26. django_richtext_link-0.3.0/richtext_link/link_index/models.py +61 -0
  27. django_richtext_link-0.3.0/richtext_link/link_model/__init__.py +0 -0
  28. django_richtext_link-0.3.0/richtext_link/link_model/admin.py +32 -0
  29. django_richtext_link-0.3.0/richtext_link/link_model/conf.py +23 -0
  30. django_richtext_link-0.3.0/richtext_link/link_model/models.py +110 -0
  31. django_richtext_link-0.3.0/richtext_link/management/__init__.py +0 -0
  32. django_richtext_link-0.3.0/richtext_link/management/commands/__init__.py +0 -0
  33. django_richtext_link-0.3.0/richtext_link/management/commands/richtext_link_editor_check.py +172 -0
  34. django_richtext_link-0.3.0/richtext_link/static/richtext_link/css/link_admin.css +81 -0
  35. django_richtext_link-0.3.0/richtext_link/static/richtext_link/css/richtext_link.css +241 -0
  36. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/link_admin_search.js +172 -0
  37. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/richtext-link-ckeditor5.js +434 -0
  38. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/richtext-link-core.js +334 -0
  39. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/richtext-link-djangocms-text.js +248 -0
  40. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/richtext-link-quill.js +253 -0
  41. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/richtext-link-tiptap.js +556 -0
  42. django_richtext_link-0.3.0/richtext_link/static/richtext_link/js/widget-init.js +216 -0
  43. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/quill/VENDOR.md +16 -0
  44. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/quill/quill.js +3 -0
  45. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/quill/quill.js.map +1 -0
  46. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/quill/quill.min.js +9 -0
  47. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/quill/quill.snow.css +10 -0
  48. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/tiptap/VENDOR.md +23 -0
  49. django_richtext_link-0.3.0/richtext_link/static/richtext_link/vendor/tiptap/tiptap-bundle.js +139 -0
  50. django_richtext_link-0.3.0/richtext_link/templates/richtext_link/widget.html +4 -0
  51. django_richtext_link-0.3.0/richtext_link/templatetags/__init__.py +0 -0
  52. django_richtext_link-0.3.0/richtext_link/templatetags/richtext_link_tags.py +33 -0
  53. django_richtext_link-0.3.0/richtext_link/tests/__init__.py +0 -0
  54. django_richtext_link-0.3.0/richtext_link/tests/get_ckeditor5.sh +11 -0
  55. django_richtext_link-0.3.0/richtext_link/tests/settings.py +160 -0
  56. django_richtext_link-0.3.0/richtext_link/tests/test_app/__init__.py +0 -0
  57. django_richtext_link-0.3.0/richtext_link/tests/test_app/admin.py +27 -0
  58. django_richtext_link-0.3.0/richtext_link/tests/test_app/fixtures/test_app.json +35 -0
  59. django_richtext_link-0.3.0/richtext_link/tests/test_app/migrations/0001_initial.py +134 -0
  60. django_richtext_link-0.3.0/richtext_link/tests/test_app/migrations/0002_testmodel_richtext_ckeditor.py +21 -0
  61. django_richtext_link-0.3.0/richtext_link/tests/test_app/migrations/0003_alter_testmodel_richtext_second.py +21 -0
  62. django_richtext_link-0.3.0/richtext_link/tests/test_app/migrations/0004_testmodel_richtext_tiptap.py +19 -0
  63. django_richtext_link-0.3.0/richtext_link/tests/test_app/migrations/__init__.py +0 -0
  64. django_richtext_link-0.3.0/richtext_link/tests/test_app/models.py +82 -0
  65. django_richtext_link-0.3.0/richtext_link/tests/test_app/templates/test_app/testmodel_detail.html +7 -0
  66. django_richtext_link-0.3.0/richtext_link/tests/test_app/views.py +7 -0
  67. django_richtext_link-0.3.0/richtext_link/tests/test_checks.py +162 -0
  68. django_richtext_link-0.3.0/richtext_link/tests/test_djangocms_text.py +108 -0
  69. django_richtext_link-0.3.0/richtext_link/tests/test_editor.py +365 -0
  70. django_richtext_link-0.3.0/richtext_link/tests/test_editor_check.py +47 -0
  71. django_richtext_link-0.3.0/richtext_link/tests/test_guards.py +62 -0
  72. django_richtext_link-0.3.0/richtext_link/tests/test_link_index.py +173 -0
  73. django_richtext_link-0.3.0/richtext_link/tests/test_search.py +93 -0
  74. django_richtext_link-0.3.0/richtext_link/tests/test_templatetag.py +150 -0
  75. django_richtext_link-0.3.0/richtext_link/tests/test_utils.py +57 -0
  76. django_richtext_link-0.3.0/richtext_link/tests/test_verify.py +84 -0
  77. django_richtext_link-0.3.0/richtext_link/tests/test_widget.py +191 -0
  78. django_richtext_link-0.3.0/richtext_link/tests/urls.py +11 -0
  79. django_richtext_link-0.3.0/richtext_link/tests/utils/__init__.py +0 -0
  80. django_richtext_link-0.3.0/richtext_link/tests/utils/selenium_utils.py +101 -0
  81. django_richtext_link-0.3.0/richtext_link/utils.py +187 -0
  82. django_richtext_link-0.3.0/richtext_link/widgets.py +101 -0
@@ -0,0 +1,61 @@
1
+ .idea/
2
+ .vscode/
3
+ *.egg-info/
4
+ *.pyc
5
+ *.coverage
6
+ *coverage/
7
+ db.sqlite
8
+ dist/
9
+ docs/_build/
10
+ .env
11
+
12
+ # Byte-compiled / optimized / DLL files
13
+ __pycache__/
14
+ *.py[cod]
15
+
16
+ # C extensions
17
+ *.so
18
+
19
+ # Distribution / packaging
20
+ .Python
21
+ env/
22
+ bin/
23
+ build/
24
+ develop-eggs/
25
+ dist/
26
+ eggs/
27
+ lib/
28
+ lib64/
29
+ parts/
30
+ sdist/
31
+ var/
32
+ *.egg-info/
33
+ .installed.cfg
34
+ *.egg
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .coverage
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+
48
+ # Translations
49
+ *.mo
50
+
51
+ # Django stuff:
52
+ *.log
53
+ *.pot
54
+
55
+ # test project artefacts
56
+ test_app_static/
57
+
58
+ # Sphinx documentation
59
+ docs/_build/
60
+ node_modules/
61
+ richtext_link/tests/test_app/static/test_ckeditor5/
@@ -0,0 +1,7 @@
1
+ Current or previous core committers
2
+
3
+ Ben Stähli
4
+
5
+ Contributors (in alphabetical order)
6
+
7
+ * Your name could stand here :)
@@ -0,0 +1,28 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ben Stähli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
23
+ ----
24
+
25
+ Quill (https://quilljs.com), bundled unmodified in
26
+ richtext_link/static/richtext_link/vendor/quill/, is licensed under the
27
+ BSD 3-Clause License, Copyright (c) 2017 Slab, Inc., 2014 Jason Chen,
28
+ 2013 salesforce.com. See the license notice in the bundled files.
@@ -0,0 +1,571 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-richtext-link
3
+ Version: 0.3.0
4
+ Summary: Model based linking for the Quill 2 rich text editor in Django - the link dialog is a plain Django admin form
5
+ Project-URL: Homepage, https://github.com/bnzk/django-richtext-link
6
+ Project-URL: Changelog, https://github.com/bnzk/django-richtext-link/blob/main/CHANGELOG.md
7
+ Author-email: Ben Stähli <bnzk@bnzk.ch>
8
+ License: The MIT License (MIT)
9
+
10
+ Copyright (c) 2026 Ben Stähli
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in
20
+ all copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
+ THE SOFTWARE.
29
+
30
+ ----
31
+
32
+ Quill (https://quilljs.com), bundled unmodified in
33
+ richtext_link/static/richtext_link/vendor/quill/, is licensed under the
34
+ BSD 3-Clause License, Copyright (c) 2017 Slab, Inc., 2014 Jason Chen,
35
+ 2013 salesforce.com. See the license notice in the bundled files.
36
+ License-File: AUTHORS
37
+ License-File: LICENSE
38
+ Keywords: django,editor,link,prosemirror,quill,richtext,tiptap,wysiwyg
39
+ Requires-Python: >=3.11
40
+ Requires-Dist: django
41
+ Provides-Extra: linkmodel
42
+ Requires-Dist: django-formfieldstash; extra == 'linkmodel'
43
+ Provides-Extra: templatetag
44
+ Requires-Dist: cssselect; extra == 'templatetag'
45
+ Requires-Dist: lxml; extra == 'templatetag'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # django-richtext-link
49
+
50
+ [![CI](https://img.shields.io/github/actions/workflow/status/bnzk/django-richtext-link/ci.yml?style=flat-square&logo=github "CI")](https://github.com/bnzk/django-richtext-link/actions/workflows/ci.yml)
51
+ [![Version](https://img.shields.io/pypi/v/django-richtext-link.svg?style=flat-square "Version")](https://pypi.python.org/pypi/django-richtext-link/)
52
+ [![Licence](https://img.shields.io/github/license/bnzk/django-richtext-link.svg?style=flat-square "Licence")](https://pypi.python.org/pypi/django-richtext-link/)
53
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/django-richtext-link.svg?style=flat-square "PyPI Downloads")](https://pypi.python.org/pypi/django-richtext-link/)
54
+
55
+ Model based linking for rich text editors in Django - **TipTap 3** (default),
56
+ **Quill 2**, and bring-your-own **CKEditor 5** - the spiritual successor of
57
+ [django-ckeditor-link](https://github.com/bnzk/django-ckeditor-link).
58
+
59
+ The link dialog is a plain Django admin form: **your link model defines the
60
+ fields**, its validation, its widgets. Nothing is ever saved to the link table -
61
+ the link data lives as a json payload in a single `data-richtext-link`
62
+ attribute on the `<a>` tag (see "Stored format"), and the real `href` is
63
+ resolved at render time. Links to objects (fk to a page, a file, whatever)
64
+ stay correct when the target moves.
65
+
66
+ Included:
67
+
68
+ - a widget (`RichtextWidget`), form field (`RichtextFormField`) and model
69
+ field (`RichtextField`), storing **plain HTML** - TipTap 3.x and Quill 2.x
70
+ are bundled, no further dependencies, the `editor` config key picks one;
71
+ CKEditor 5 is supported bring-your-own (it is GPL/commercially licensed
72
+ and not bundled)
73
+ - TipTap extras: per block **classes** on paragraphs/headings
74
+ (`blockClasses`), `enterMode: "br"` (CKEditor ENTER_BR style)
75
+ - the django link dialog as editor modules/extensions (vanilla JS, usable
76
+ with your own TipTap or Quill build)
77
+ - `DjangoLinkAdmin`, rendering your link model's admin form in an iframe
78
+ dialog, with ajax `verify/` and `search/` endpoints (quick search over all
79
+ fk link targets, based on each target admin's `search_fields`)
80
+ - the `richtext_link_add_links` template filter, resolving stored links at
81
+ render time
82
+ - optional: abstract link model base (`richtext_link.link_model`)
83
+ - optional: a rebuildable link index (`richtext_link.link_index`) - "what
84
+ links here?", link reports, advisory delete protection
85
+ - content compatible with django-ckeditor-link and django-quilljs-link:
86
+ legacy `data-ckeditor-link` / `data-quilljs-link` markers are recognized
87
+ everywhere, and upgraded to `data-richtext-link` on the next editor save
88
+
89
+ ## Quickstart
90
+
91
+ Install:
92
+
93
+ ```bash
94
+ pip install django-richtext-link
95
+ # lxml + cssselect are needed for the template filter (and the link index):
96
+ pip install django-richtext-link[templatetag]
97
+ ```
98
+
99
+ Settings:
100
+
101
+ ```python
102
+ INSTALLED_APPS = [
103
+ ...
104
+ "richtext_link",
105
+ # optional, the link index ("what links here?"):
106
+ "richtext_link.link_index",
107
+ ]
108
+
109
+ RICHTEXT_LINK_MODEL = "my_app.models.LinkModel"
110
+
111
+ # the link dialog is an iframe:
112
+ X_FRAME_OPTIONS = "SAMEORIGIN"
113
+ ```
114
+
115
+ Define your link model. It is never saved - it provides the dialog form and
116
+ the link resolution. `get_link()` is required, `get_link_target()`,
117
+ `get_link_style()` (css class) and `get_link_attrs()` (extra attributes) are
118
+ optional:
119
+
120
+ ```python
121
+ class LinkModel(models.Model):
122
+ external_url = models.URLField(blank=True, default="")
123
+ page = models.ForeignKey(Page, null=True, blank=True, on_delete=models.CASCADE)
124
+
125
+ def get_link(self):
126
+ if self.external_url:
127
+ return self.external_url
128
+ if self.page:
129
+ return self.page.get_absolute_url()
130
+ return ""
131
+
132
+ def get_link_target(self):
133
+ return "_blank" if self.external_url else ""
134
+ ```
135
+
136
+ Or start from the included abstract base
137
+ (`richtext_link.link_model.models.LinkBase`, with free/external/mailto/phone
138
+ fields and a `link_type` dispatcher).
139
+
140
+ Register it with `DjangoLinkAdmin` (this also adds the `verify/` and
141
+ `search/` endpoints, no urls.py include needed):
142
+
143
+ ```python
144
+ from richtext_link.admin import DjangoLinkAdmin
145
+
146
+ @admin.register(LinkModel)
147
+ class LinkModelAdmin(DjangoLinkAdmin):
148
+ pass # a custom form/widgets? just set form = ...
149
+ ```
150
+
151
+ Use the field on your models:
152
+
153
+ ```python
154
+ from richtext_link.fields import RichtextField
155
+
156
+ class Article(models.Model):
157
+ body = RichtextField(default="", blank=True)
158
+ ```
159
+
160
+ The editor's dialog urls are derived from `RICHTEXT_LINK_MODEL`'s admin
161
+ automatically. Render the content with resolved links:
162
+
163
+ ```django
164
+ {% load richtext_link_tags %}
165
+ {{ article.body|richtext_link_add_links|safe }}
166
+ ```
167
+
168
+ ## Settings
169
+
170
+ | Setting | Default | Description |
171
+ |---|---|---|
172
+ | `RICHTEXT_LINK_MODEL` | `None` | Dotted path to your link model class. |
173
+ | `RICHTEXT_LINK_CONFIGS` | see below | Editor configs; `RichtextField(config_name="...")` picks one. |
174
+ | `RICHTEXT_LINK_ATTR_MODIFIERS` | `{}` | `{field: format_string}` - recombine link data at render time, mostly for MultiWidget fields (e.g. `{"cms_page": "{cms_page_2}"}`). |
175
+ | `RICHTEXT_LINK_SANITIZER` | `None` | Dotted path to your html sanitize callable - a startup system check verifies the stored link format survives it (see "Stored format & sanitizers"). |
176
+ | `RICHTEXT_LINK_LEGACY_CHECK` | `True` | Checks also verify the legacy link formats survive (content never resaved since django-ckeditor-link / 0.1 / 0.2). Set `False` once no such content is left. |
177
+ | `RICHTEXT_LINK_SAVE_GUARD` | `True` | Logs an error (`richtext_link` logger) when a save loses link data the stored value had (see "Stored format & sanitizers"). |
178
+ | `RICHTEXT_LINK_INDEX_PROTECT` | `False` | link_index: raise when deleting a link target. |
179
+
180
+ ## Editors
181
+
182
+ Each config in `RICHTEXT_LINK_CONFIGS` selects its editor via the `editor`
183
+ key: `"tiptap"` (the default) or `"quilljs"`. The widget only loads the
184
+ assets of the configured editor.
185
+
186
+ ### TipTap (default)
187
+
188
+ TipTap configs use django-richtext-link's own schema (TipTap itself is
189
+ headless and not JSON-configurable):
190
+
191
+ ```python
192
+ RICHTEXT_LINK_CONFIGS = {
193
+ "default": {
194
+ "editor": "tiptap",
195
+ "toolbar": [
196
+ ["bold", "italic"],
197
+ ["h2", "h3", "p"],
198
+ ["bulletList", "orderedList"],
199
+ ["djangolink", "clear"],
200
+ ],
201
+ "headingLevels": [2, 3],
202
+ "enterMode": "p",
203
+ "blockClasses": {
204
+ "paragraph": [["", "Default"], ["intro", "Intro"]],
205
+ "heading": [["", "Default"], ["fancy", "Fancy"]],
206
+ },
207
+ # iframeUrl/verifyUrl are auto derived from RICHTEXT_LINK_MODEL's
208
+ # admin when not set; omit the key entirely for no link feature:
209
+ "djangolink": {},
210
+ },
211
+ }
212
+ ```
213
+
214
+ | key | default | description |
215
+ |---|---|---|
216
+ | `toolbar` | see above | button groups; items: `bold`, `italic`, `underline`, `strike`, `h1`…`h6`, `p`, `bulletList`, `orderedList`, `blockquote`, `djangolink`, `clear`, `undo`, `redo`, `blockClass` |
217
+ | `headingLevels` | `[2, 3]` | allowed heading levels |
218
+ | `enterMode` | `"p"` | `"p"`: Enter = new paragraph, Shift+Enter = `<br>`; `"br"`: Enter inserts `<br>` (CKEditor ENTER_BR style; lists/code keep their Enter behavior) |
219
+ | `blockClasses` | — | per node type `[value, label]` class choices, shown as a toolbar select; the `class` attribute round-trips through the stored HTML |
220
+ | `djangolink` | — | `{iframeUrl, verifyUrl, fallbackField, linkText, okLabel, cancelLabel, errorMessage}` |
221
+
222
+ `fallbackField` (default `"external"`) is where a plain `<a href>`'s url
223
+ lands when editing pre-django-link content; `linkText` (default `"link"`)
224
+ is inserted when nothing is selected.
225
+
226
+ ### Quill
227
+
228
+ Quill configs are passed to `new Quill(el, config)` as is (minus our
229
+ `editor` key) - the `djangolink` module options live in `modules`:
230
+
231
+ ```python
232
+ RICHTEXT_LINK_CONFIGS = {
233
+ "default": { ... }, # tiptap, as above
234
+ "legacy": {
235
+ "editor": "quilljs",
236
+ "theme": "snow",
237
+ "modules": {
238
+ "toolbar": [
239
+ ["bold", "italic", "underline"],
240
+ [{"list": "ordered"}, {"list": "bullet"}],
241
+ ["djangolink", "clean"],
242
+ ],
243
+ "djangolink": {},
244
+ },
245
+ },
246
+ }
247
+ ```
248
+
249
+ Note: Quill has no soft line breaks (`<br>`) and no per block classes -
250
+ that's why TipTap is the default.
251
+
252
+ ### CKEditor 5 (bring your own)
253
+
254
+ django-richtext-link is MIT licensed and ships **no CKEditor code**:
255
+ CKEditor 5 is GPL-2.0-or-later (or commercially) licensed, and since v44
256
+ requires a `licenseKey` - `"GPL"` (the adapter's default, shows a small
257
+ "Powered by CKEditor" logo) or your commercial key. Self-host the official
258
+ `ckeditor5` npm package's `dist/browser/` files, or serve them via jsdelivr
259
+ (below); `cdn.ckeditor.com` is part of CKEditor's cloud plans.
260
+
261
+ ```python
262
+ RICHTEXT_LINK_CONFIGS = {
263
+ "default": { ... }, # tiptap, as above
264
+ "ckeditor": {
265
+ "editor": "ckeditor5",
266
+ # optional: the widget loads these for you (absolute url or static
267
+ # path); omit = load a build exposing window.CKEDITOR yourself
268
+ "assets": {
269
+ "js": "https://cdn.jsdelivr.net/npm/ckeditor5@48.3.1/dist/browser/ckeditor5.umd.js",
270
+ "css": "https://cdn.jsdelivr.net/npm/ckeditor5@48.3.1/dist/browser/ckeditor5.css",
271
+ },
272
+ "licenseKey": "GPL",
273
+ # plugin names resolved against window.CKEDITOR; default:
274
+ # ["Essentials", "Paragraph", "Heading", "Bold", "Italic", "List",
275
+ # "LinkEditing"] - LinkEditing (not "Link") on purpose: no LinkUI
276
+ # balloon interfering with the django link dialog
277
+ # "plugins": [...],
278
+ "enterMode": "p", # or "br": enter inserts <br>
279
+ "djangolink": {}, # urls auto derived, like tiptap
280
+ "ckeditor": { # native CKEditor 5 config, passed through as is
281
+ "toolbar": ["heading", "|", "bold", "italic", "|",
282
+ "bulletedList", "numberedList", "|", "djangolink"],
283
+ },
284
+ },
285
+ }
286
+ ```
287
+
288
+ Block classes work via CKEditor's native (free) **Style** feature - the
289
+ tiptap `blockClasses` equivalent is plain passthrough config: add `"Style"`
290
+ and `"GeneralHtmlSupport"` to `plugins`, `"style"` to the toolbar, and e.g.
291
+ `"style": {"definitions": [{"name": "Intro", "element": "p", "classes":
292
+ ["intro"]}]}` to `ckeditor`. Do not `htmlSupport.allow` (or `disallow`)
293
+ `data-*` attributes on `a` - the adapter owns the marker attribute, and a
294
+ startup system check (`richtext_link.E006`) refuses such configs.
295
+
296
+ Links are edited via the `djangolink` toolbar button or double click.
297
+
298
+ ### djangocms-text (TipTap)
299
+
300
+ For [djangocms-text](https://github.com/django-cms/djangocms-text) the bridge
301
+ plugs into its *bundled* TipTap editor (TipTap 3, same as our adapter) via
302
+ djangocms-text's contrib registry - not a `RICHTEXT_LINK_CONFIGS` editor, but
303
+ an `RTEConfig`:
304
+
305
+ ```python
306
+ # settings.py
307
+ TEXT_EDITOR = "richtext_link.contrib.djangocms_text.rte_config"
308
+ RICHTEXT_LINK_MODEL = "myproject.models.Link"
309
+ ```
310
+
311
+ or instantiate `RichtextLinkRTEConfig(js=..., css=..., djangolink=...)` in
312
+ your project to add own editor assets (see the module docstring).
313
+
314
+ What it does: the toolbar `Link` button and clicks on links open the
315
+ django link dialog instead of djangocms-text's built-in link form, and the
316
+ marker attribute survives djangocms-text's schema (which would otherwise
317
+ strip it on save). Legacy `data-ckeditor-link`/`data-quilljs-link` content
318
+ is read and upgraded on the next save, as everywhere.
319
+
320
+ Rendering stays yours: djangocms-text does not run our resolution, so pipe
321
+ the output through the `richtext_link_add_links` template filter in your
322
+ text plugin templates (the stored fallback `href` covers anything that
323
+ slips through unfiltered). djangocms-text's inline editing mode carries no
324
+ custom editor configuration, so the dialog opens in field editors (plugin
325
+ modal, `HTMLField`) only - data preservation works everywhere.
326
+
327
+ ## Stored format & sanitizers
328
+
329
+ A django link is stored as plain HTML with one attribute, the marker, whose
330
+ value is the json encoded link data (your link model's field values, sorted
331
+ keys, empty fields omitted):
332
+
333
+ ```html
334
+ <a data-richtext-link='{"link_type":"page","page":"3"}'>read more</a>
335
+ ```
336
+
337
+ The single attribute is the whole contract: whitelisting django links in a
338
+ sanitizer or html schema means allowing exactly `a[href, data-richtext-link]`
339
+ - e.g. nh3/bleach `attributes={"a": {"href", "data-richtext-link"}}`, or
340
+ DOMPurify `ADD_ATTR: ["data-richtext-link"]`.
341
+
342
+ Read forever, upgraded to the current format on the next editor save:
343
+
344
+ - `data-richtext-link="true"` plus one `data-<field>` attribute per field
345
+ (django-richtext-link 0.2)
346
+ - the same attribute format with the `data-quilljs-link` marker
347
+ (django-quilljs-link 0.1) or the `data-ckeditor-link` marker
348
+ (django-ckeditor-link)
349
+
350
+ Two rules keep the format safe:
351
+
352
+ 1. **Never assemble the attribute with string formatting** (f-strings in
353
+ templates/migration scripts): a quote in a value breaks out of the
354
+ attribute. Produce content through an editor or lxml, or escape the
355
+ dumped json (`django.utils.html.escape`). From Python,
356
+ `richtext_link.utils.serialize_link_data(data)` gives the canonical
357
+ payload.
358
+ 2. **Anything rewriting stored content must keep the marker attribute** - a
359
+ sanitizer allowlist missing the exception silently destroys link data on
360
+ the next save. Point `RICHTEXT_LINK_SANITIZER` at your sanitize callable
361
+ and a Django system check round trips a probe link through it at startup:
362
+ `runserver`/`migrate` refuse to start when the payload does not survive
363
+ (`richtext_link.E001-E004`; editor configs are checked too, `E005/E006`).
364
+
365
+ ```python
366
+ # settings.py - optional, strongly recommended when you sanitize content
367
+ RICHTEXT_LINK_SANITIZER = "myproject.utils.sanitize_richtext"
368
+ ```
369
+
370
+ With `RICHTEXT_LINK_LEGACY_CHECK = True` (the default) the sanitizer probe
371
+ also covers the legacy formats - content saved before the json format and
372
+ never resaved must not lose its link data either. Set it to `False` once no
373
+ such content is left (the strict one-attribute allowlist is then enough).
374
+
375
+ **The editors are sanitizers too** - a strict Quill `formats` whitelist, a
376
+ CKEditor plugin setup or your own editor build can strip links the moment
377
+ content loads. Three guards cover that side:
378
+
379
+ - static config analysis at startup (part of the system checks above, e.g. a
380
+ Quill `formats` whitelist without `"link"`)
381
+ - every widget verifies at editor init that the loaded content's link data
382
+ survives the editor's parse/serialize round trip (legacy formats load
383
+ upgraded, so only real loss counts) - on loss: a loud console error plus a
384
+ visible warning under the editor, before anything can be saved
385
+ - `manage.py richtext_link_editor_check` - the same round trip with probe
386
+ links (current + legacy formats), driven through every configured editor
387
+ in headless firefox, exiting non-zero on loss. Run it in CI or predeploy,
388
+ next to `manage.py check --deploy` (needs selenium + geckodriver;
389
+ bring-your-own CKEditor configs without `assets` are reported as skipped)
390
+
391
+ **The last safety net** runs at save time, server side: a `pre_save`
392
+ comparison logs an error on the `richtext_link` logger whenever a saved
393
+ value lost link data the stored value had - specifically a link whose anchor
394
+ survived but whose payload vanished, the signature of a stripping sanitizer
395
+ or editor (deliberate link removals and dialog edits stay silent). Whatever
396
+ save path the other guards never saw - a foreign admin form, an api, a
397
+ custom sanitizer - the loss surfaces here. Wire the `richtext_link` logger
398
+ to your error reporting; `RICHTEXT_LINK_SAVE_GUARD = False` disables it.
399
+
400
+ What no check can see: content mangled outside of model saves (raw sql,
401
+ `queryset.update()`). The link index below is the tripwire for that -
402
+ `rebuild_richtext_link_index` after sanitizer or editor changes, and watch
403
+ the link count.
404
+
405
+ ## Quick search in the link dialog
406
+
407
+ The dialog form gets a search field on top that searches **all foreign key
408
+ link targets at once** - every fk field of your link model whose target
409
+ model has a `ModelAdmin` with `search_fields`. Results are grouped per
410
+ field; picking one fills the fk field (and `link_type`, when your link model
411
+ has a matching one). No configuration needed - it appears as soon as there
412
+ is something searchable, and stays away otherwise.
413
+
414
+ On your `DjangoLinkAdmin` subclass: `link_search = False` disables it,
415
+ `link_search_limit` (default `10`) caps results per target. Permissions of
416
+ the target model admins are respected.
417
+
418
+ ## Nicer link_type forms: django-formfieldstash
419
+
420
+ For `link_type` dispatcher style link models (like the contrib `LinkBase`),
421
+ [django-formfieldstash](https://github.com/bnzk/django-formfieldstash) makes
422
+ the dialog show only the field of the selected type. The contrib
423
+ `richtext_link.link_model.admin.LinkBaseAdmin` applies it automatically when
424
+ installed (and is a plain `DjangoLinkAdmin` when not):
425
+
426
+ ```bash
427
+ pip install django-richtext-link[linkmodel]
428
+ ```
429
+
430
+ ```python
431
+ INSTALLED_APPS = [
432
+ ...
433
+ "formfieldstash", # so its static files are served
434
+ ]
435
+ ```
436
+
437
+ ```python
438
+ from richtext_link.link_model.admin import LinkBaseAdmin
439
+
440
+ @admin.register(LinkModel)
441
+ class LinkModelAdmin(LinkBaseAdmin):
442
+ pass
443
+ ```
444
+
445
+ ## Using your own editor build
446
+
447
+ The Django side does not care where the editor comes from.
448
+
449
+ **TipTap**: load `richtext_link/js/richtext-link-core.js` and
450
+ `richtext_link/js/richtext-link-tiptap.js`, and expose your own
451
+ `@tiptap/*` imports as `window.RichtextLinkTiptap = {Editor, Extension,
452
+ Mark, Node, mergeAttributes, StarterKit, Link}` (what the vendored bundle
453
+ does, see `frontend/tiptap-entry.js`). Then either let
454
+ `RichtextLink.tiptap.createEditor(el, config, {content, onUpdate})` build
455
+ toolbar and all, or use `RichtextLink.tiptap.buildExtensions(config)` with
456
+ your own `Editor` setup.
457
+
458
+ **CKEditor 5**: anything exposing `window.CKEDITOR` with `ClassicEditor`,
459
+ `Plugin`, `ButtonView` and your plugins works (the UMD build does). Load
460
+ `richtext-link-core.js` + `richtext-link-ckeditor5.js`, then use
461
+ `RichtextLink.ckeditor5.createEditor(el, config, {content, onUpdate})`, or
462
+ `buildConfig`/`buildDjangoLinkPlugin` with your own `ClassicEditor.create`.
463
+
464
+ **Quill**: load `richtext-link-core.js` and `richtext-link-quill.js`, then:
465
+
466
+ ```js
467
+ import Quill from "quill";
468
+ window.RichtextLink.quill.register(Quill); // link blot + "djangolink" module + icon
469
+
470
+ new Quill(el, {
471
+ theme: "snow",
472
+ modules: {
473
+ toolbar: [["bold"], ["djangolink"]],
474
+ djangolink: {
475
+ iframeUrl: "/admin/my_app/linkmodel/add/",
476
+ verifyUrl: "/admin/my_app/linkmodel/verify/",
477
+ },
478
+ },
479
+ });
480
+ ```
481
+
482
+ Sync the editor's html into your form field - or use
483
+ `richtext_link/js/widget-init.js` as a reference.
484
+
485
+ ## Link index: what links here?
486
+
487
+ With `richtext_link.link_index` in `INSTALLED_APPS` (and a `migrate`), an
488
+ index of all links in `RichtextField` contents is maintained on every save -
489
+ browsable in the admin (read only), queryable in code:
490
+
491
+ ```python
492
+ from richtext_link.link_index.index import get_inbound_links, register
493
+
494
+ get_inbound_links(some_page) # queryset of LinkIndexEntry targeting some_page
495
+ ```
496
+
497
+ - the index is **derived data**: rebuild it anytime with
498
+ `./manage.py rebuild_richtext_link_index` (also needed after `loaddata`,
499
+ fixtures are skipped on purpose, and after `queryset.update()`, which
500
+ bypasses signals)
501
+ - models using `RichtextField` are picked up automatically; for plain
502
+ `TextField` + `RichtextWidget` combinations, call
503
+ `register(MyModel, ["my_field"])` (e.g. in `AppConfig.ready`)
504
+ - `RICHTEXT_LINK_INDEX_PROTECT = True` raises
505
+ `LinkedObjectProtectedError` when a link target is deleted - advisory
506
+ protection, based on the index
507
+
508
+ ## Migrating from django-quilljs-link 0.1.0
509
+
510
+ Hard rename, **content needs no migration** - `data-quilljs-link` markers
511
+ are read forever and upgraded on the next editor save. In code:
512
+
513
+ - `pip uninstall django-quilljs-link`, install `django-richtext-link`
514
+ - `INSTALLED_APPS`: `quilljs_link*` → `richtext_link*`
515
+ - settings: `QUILLJS_LINK_*` → `RICHTEXT_LINK_*`; `QUILLJS_LINK_CONFIGS`
516
+ entries keep working as quill configs when you add `"editor": "quilljs"`
517
+ (the new default editor is TipTap)
518
+ - fields/widgets: `QuillField/QuillFormField/QuillWidget` →
519
+ `RichtextField/RichtextFormField/RichtextWidget`
520
+ - template filter: `{% load quilljs_link_tags %}` /
521
+ `|quilljs_link_add_links` → `{% load richtext_link_tags %}` /
522
+ `|richtext_link_add_links`
523
+ - link index: `./manage.py migrate` creates the new table, then run
524
+ `./manage.py rebuild_richtext_link_index` once; the old
525
+ `quilljs_link_index_linkindexentry` table stays behind - drop it manually
526
+ or leave it
527
+
528
+ ## Migrating from django-ckeditor-link
529
+
530
+ Content works unchanged: the editors and the template filter recognize
531
+ `data-ckeditor-link` markers, links upgrade to `data-richtext-link` on the
532
+ next editor save. Rename settings (`CKEDITOR_LINK_MODEL` →
533
+ `RICHTEXT_LINK_MODEL` etc.), swap `RichTextField` for `RichtextField`, use
534
+ the `richtext_link_add_links` filter, keep `X_FRAME_OPTIONS = "SAMEORIGIN"`.
535
+ Plain pre-django-link links: the `fallbackField` option prefills a field of
536
+ your choice with the old `href` when such a link is edited.
537
+
538
+ One heads-up that applies to all generations of this package: the stored
539
+ `data-*` attributes are your link model's **field names**. Renaming a link
540
+ model field silently orphans that attribute in existing content - migrate
541
+ your content when renaming.
542
+
543
+ ## Development / running the test project
544
+
545
+ ```bash
546
+ git clone https://github.com/bnzk/django-richtext-link.git
547
+ cd django-richtext-link
548
+ pip install -r requirements_dev.txt
549
+ ./manage.py migrate
550
+ ./manage.py createsuperuser
551
+ ./manage.py loaddata test_app
552
+ ./manage.py rebuild_richtext_link_index
553
+ ./manage.py runserver
554
+ ```
555
+
556
+ Then check `/admin/test_app/testmodel/2/change/` (TipTap + Quill + CKEditor
557
+ editors, dialog) and `/testmodel/2/` (rendered output). For the CKEditor 5
558
+ editor and its Selenium tests, run `richtext_link/tests/get_ckeditor5.sh`
559
+ once (downloads the GPL licensed build into a gitignored dir - it is not
560
+ part of this package). Tests: `./manage.py test`
561
+ (the Selenium editor tests need Firefox + geckodriver and are skipped
562
+ otherwise; set `RICHTEXT_LINK_SKIP_SELENIUM=1` to skip them explicitly), or
563
+ `tox`.
564
+
565
+ Vendored editors: Quill 2.x unmodified
566
+ (`richtext_link/static/richtext_link/vendor/quill/VENDOR.md`); TipTap 3.x as
567
+ our own esbuild bundle of unmodified packages
568
+ (`.../vendor/tiptap/VENDOR.md`) - TipTap ships no browser build, rebuild
569
+ with `cd frontend && npm ci && npm run build` (maintainers only, hosts
570
+ install nothing). django-richtext-link is MIT licensed, bundled Quill is
571
+ BSD-3-Clause, bundled TipTap is MIT.