Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f08798a
Add spec for Medium-style body editor
Kanyandula May 14, 2026
dd915e5
Add implementation plan for Medium-style body editor
Kanyandula May 14, 2026
82f664c
Add Tiptap, esbuild, and vitest toolchain
Kanyandula May 14, 2026
78bc5af
Add Office/Docs/Notion paste normaliser
Kanyandula May 14, 2026
26a0f18
Add smart paste dispatcher (Office HTML + markdown + image drop)
Kanyandula May 14, 2026
499be13
Document MARKDOWN_HINTS regex alternatives
Kanyandula May 14, 2026
a4ae5f4
Add slash menu extension for inserting blocks
Kanyandula May 14, 2026
24e045b
Add Tiptap editor entry point (StarterKit + bubble + slash + paste)
Kanyandula May 14, 2026
814d2c9
Add Tiptap editor stylesheet
Kanyandula May 14, 2026
edfaba6
Add Tiptap widget template
Kanyandula May 14, 2026
59ca271
Add TiptapWidget with hidden textarea + mount template
Kanyandula May 14, 2026
611bd3c
Fix: keep textarea body HTML-escaped (XSS hardening)
Kanyandula May 14, 2026
031b9e6
Use TiptapWidget for BlogPost body in create + update forms
Kanyandula May 14, 2026
7801b31
Switch BlogPost.body from CKEditor5Field to TextField
Kanyandula May 14, 2026
8adba84
Remove django-ckeditor-5 (replaced by Tiptap)
Kanyandula May 14, 2026
9083597
Build Tiptap editor bundle
Kanyandula May 14, 2026
5d21268
Make Tiptap editor 500px-tall clickable surface
Kanyandula May 14, 2026
935946c
Pad Tiptap editor (2.5rem/3rem) and suppress inner focus ring
Kanyandula May 14, 2026
c80558d
Let Tiptap manage bubble-menu visibility (was display:none-blocked)
Kanyandula May 14, 2026
9fc9db9
Drop initial aria-hidden on bubble menu (Tiptap toggles visibility)
Kanyandula May 14, 2026
be42b0a
Add @tailwindcss/typography for prose-rendered article bodies
Kanyandula May 14, 2026
f0dabde
Exclude built artifacts from detect-secrets scan
Kanyandula May 14, 2026
cd8b1c7
Refresh secrets baseline line numbers
Kanyandula May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 22 additions & 33 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,26 @@
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"static/blog/tiptap\\.js$",
"static/css/tailwind\\.min\\.css$",
"package-lock\\.json$",
"\\.venv/",
"node_modules/"
]
}
],
"results": {
".github/workflows/tests.yml": [
{
"type": "Secret Keyword",
"filename": ".github/workflows/tests.yml",
"hashed_secret": "3c90011fe8d955a8938382f77cafa2016b03413e",
"is_verified": false,
"line_number": 34,
"is_secret": false
}
],
"account/api/serializers.py": [
{
"type": "Secret Keyword",
"filename": "account/api/serializers.py",
"hashed_secret": "ba82d7311b12e96451206a9202f2300add40126a",
"is_verified": false,
"line_number": 38,
"is_secret": false
"line_number": 38
}
],
"account/tests.py": [
Expand All @@ -153,56 +152,49 @@
"filename": "account/tests.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 74,
"is_secret": false
"line_number": 74
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "1816851d10187b57a93235b52495e615629f906d",
"is_verified": false,
"line_number": 103,
"is_secret": false
"line_number": 103
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "0fa86f7cd4925d1bc1f299fefdaeb3cede77592f",
"is_verified": false,
"line_number": 146,
"is_secret": false
"line_number": 146
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d",
"is_verified": false,
"line_number": 176,
"is_secret": false
"line_number": 176
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "0d8b28805975effded2c628b96d75cd3b47bbdcf",
"is_verified": false,
"line_number": 257,
"is_secret": false
"line_number": 257
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "1151a8c33d6ae2daf21ad0d466488e707c1c7f8d",
"is_verified": false,
"line_number": 281,
"is_secret": false
"line_number": 281
},
{
"type": "Secret Keyword",
"filename": "account/tests.py",
"hashed_secret": "bdb8465ce041d94a0e490564f2162dcc87d4a46a",
"is_verified": false,
"line_number": 289,
"is_secret": false
"line_number": 289
}
],
"blog/tests.py": [
Expand All @@ -211,8 +203,7 @@
"filename": "blog/tests.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 13,
"is_secret": false
"line_number": 13
}
],
"blog/tests_api.py": [
Expand All @@ -221,8 +212,7 @@
"filename": "blog/tests_api.py",
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
"is_verified": false,
"line_number": 32,
"is_secret": false
"line_number": 32
}
],
"settings.ini.example": [
Expand All @@ -231,10 +221,9 @@
"filename": "settings.ini.example",
"hashed_secret": "29b8dca3de5ff27bcf8bd3b622adf9970f29381c",
"is_verified": false,
"line_number": 2,
"is_secret": false
"line_number": 2
}
]
},
"generated_at": "2026-05-14T13:07:32Z"
"generated_at": "2026-05-14T23:21:05Z"
}
4 changes: 3 additions & 1 deletion blog/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django import forms

from blog.models import BlogPost, Comment
from blog.widgets import TiptapWidget


class CommentForm(forms.ModelForm):
Expand All @@ -20,6 +21,7 @@ class Meta:
fields = ['title', 'body', 'image', 'category', 'tags', 'status']
widgets = {
'tags': forms.CheckboxSelectMultiple(),
'body': TiptapWidget(),
}


Expand All @@ -30,10 +32,10 @@ class Meta:
fields = ['title', 'body', 'image', 'category', 'tags', 'status']
widgets = {
'tags': forms.CheckboxSelectMultiple(),
'body': TiptapWidget(),
}

def save(self, commit=True):
# Keep existing image if no new one uploaded
if not self.cleaned_data.get('image'):
self.cleaned_data['image'] = self.instance.image
return super().save(commit=commit)
5 changes: 2 additions & 3 deletions blog/migrations/0009_alter_blogpost_body.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Generated by Django 5.2.12 on 2026-04-02 19:45

import django_ckeditor_5.fields
from django.db import migrations
from django.db import migrations, models


class Migration(migrations.Migration):
Expand All @@ -14,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='blogpost',
name='body',
field=django_ckeditor_5.fields.CKEditor5Field(blank=True, max_length=20000),
field=models.TextField(blank=True, max_length=20000),
),
]
18 changes: 18 additions & 0 deletions blog/migrations/0014_alter_blogpost_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-14 18:40

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('blog', '0013_rebackfill_body_plain_unescape'),
]

operations = [
migrations.AlterField(
model_name='blogpost',
name='body',
field=models.TextField(blank=True, max_length=20000),
),
]
3 changes: 1 addition & 2 deletions blog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from django.dispatch import receiver
from django.utils.html import strip_tags
from django.utils.text import slugify
from django_ckeditor_5.fields import CKEditor5Field


def upload_location(instance, filename):
Expand Down Expand Up @@ -49,7 +48,7 @@ class BlogPost(models.Model):
]

title = models.CharField(max_length=50, null=False, blank=True)
body = CKEditor5Field(max_length=20000, blank=True, config_name='default')
body = models.TextField(max_length=20000, blank=True)
# Plain-text mirror of body, populated in pre_save. Search uses this so HTML
# tag names (div, class, style) don't leak as matches against the rich field.
body_plain = models.TextField(blank=True, editable=False)
Expand Down
19 changes: 19 additions & 0 deletions blog/templates/blog/widgets/tiptap.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="tiptap-shell">
<textarea name="{{ widget.name }}"
{% if widget.attrs.required %}required{% endif %}
data-tiptap-input
hidden>{{ widget.value|default_if_none:"" }}</textarea>

<div data-tiptap-root class="tiptap-root">
<div data-bubble class="bubble-menu" role="toolbar">
<button type="button" data-cmd="toggleBold" aria-label="Bold"><b>B</b></button>
<button type="button" data-cmd="toggleItalic" aria-label="Italic"><i>I</i></button>
<button type="button" data-cmd="toggleUnderline" aria-label="Underline"><u>U</u></button>
<button type="button" data-cmd="toggleHeading:2">H2</button>
<button type="button" data-cmd="toggleHeading:3">H3</button>
<button type="button" data-cmd="toggleBlockquote">&ldquo;</button>
<button type="button" data-cmd="setLink">link</button>
</div>
<div data-editor class="prose prose-lg max-w-none min-h-[500px]"></div>
</div>
</div>
104 changes: 104 additions & 0 deletions blog/tests_tiptap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from django.test import TestCase
from django.urls import reverse

from account.models import Account
from blog.forms import CreateBlogPostForm
from blog.models import BlogPost, Category
from blog.widgets import TiptapWidget


class TiptapWidgetRenderTests(TestCase):
def test_renders_hidden_textarea(self):
html = TiptapWidget().render('body', '<p>hi</p>')
self.assertIn('<textarea', html)
self.assertIn('hidden', html)
self.assertIn('data-tiptap-input', html)

def test_renders_editor_mount_and_bubble(self):
html = TiptapWidget().render('body', '')
self.assertIn('data-tiptap-root', html)
self.assertIn('data-bubble', html)
self.assertIn('data-editor', html)

def test_preserves_initial_value(self):
html = TiptapWidget().render('body', '<h2>Hi</h2>')
self.assertIn('&lt;h2&gt;Hi&lt;/h2&gt;', html)

def test_initial_value_html_escaped_to_prevent_textarea_breakout(self):
html = TiptapWidget().render('body', '</textarea><script>x</script>')
self.assertNotIn('</textarea><script>', html)
self.assertIn('&lt;/textarea&gt;', html)

def test_media_includes_tiptap_assets(self):
widget = TiptapWidget()
self.assertIn('blog/tiptap.js', str(widget.media))
self.assertIn('blog/tiptap.css', str(widget.media))


class TiptapFormRoundtripTests(TestCase):
def setUp(self):
self.user = Account.objects.create_user(
email='t@nyasablog.com', username='t', password='p',
)
self.user.email_verified = True
self.user.save()
self.category, _ = Category.objects.get_or_create(
name='Culture', slug='culture', defaults={'description': 'x'},
)

def test_form_uses_tiptap_widget_for_body(self):
form = CreateBlogPostForm()
self.assertIsInstance(form.fields['body'].widget, TiptapWidget)

def test_form_roundtrip_preserves_html(self):
form = CreateBlogPostForm({
'title': 'T',
'body': '<h2>X</h2><p>Y</p>',
'category': self.category.id,
'status': 'draft',
})
self.assertTrue(form.is_valid(), form.errors)
post = form.save(commit=False)
post.author = self.user
post.save()
form.save_m2m()
post.refresh_from_db()
self.assertEqual(post.body, '<h2>X</h2><p>Y</p>')


class SanitiserRegressionTests(TestCase):
"""The editor swap MUST NOT regress the server-side sanitiser."""

def setUp(self):
self.user = Account.objects.create_user(
email='s@nyasablog.com', username='s', password='p',
)
self.user.email_verified = True
self.user.save()
self.category, _ = Category.objects.get_or_create(
name='Tech', slug='tech', defaults={'description': 'x'},
)

def test_detail_view_strips_style_attribute(self):
post = BlogPost.objects.create(
title='S',
body='<p style="color:red">X</p>',
author=self.user, category=self.category, status='published',
)
r = self.client.get(reverse('blog:detail', args=[post.slug]))
self.assertEqual(r.status_code, 200)
self.assertNotIn(b'style=', r.content)
self.assertIn(b'<p>X</p>', r.content)

def test_detail_view_strips_script(self):
post = BlogPost.objects.create(
title='S2',
body='<p>safe</p><script>alert(1)</script>',
author=self.user, category=self.category, status='published',
)
r = self.client.get(reverse('blog:detail', args=[post.slug]))
self.assertEqual(r.status_code, 200)
# The sanitiser must strip the <script> tag from the rendered body.
# The page has its own legitimate <script> tags, so we assert the
# injected payload is absent from the article body section specifically.
self.assertNotIn(b'<script>alert(1)</script>', r.content)
21 changes: 21 additions & 0 deletions blog/widgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django import forms


class TiptapWidget(forms.Textarea):
"""Renders a hidden <textarea> + a Tiptap mount.

The JS bundle (static/blog/tiptap.js) initialises Tiptap on every
.tiptap-shell and mirrors editor.getHTML() back to the hidden textarea
on every input event. Server receives plain text (the HTML string),
exactly as it did with CKEditor 5.
"""

template_name = 'blog/widgets/tiptap.html'

class Media:
css = {'all': ['blog/tiptap.css']}
js = ['blog/tiptap.js']

def __init__(self, attrs=None):
defaults = {'hidden': True, 'data-tiptap-input': ''}
super().__init__({**defaults, **(attrs or {})})
Loading
Loading