diff --git a/blog/admin.py b/blog/admin.py index 204c77e1..653c4358 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -3,7 +3,6 @@ from django.db.models.functions import Length from django.db.models import F from django import forms -from xml.etree import ElementTree from .models import ( Entry, Tag, @@ -44,10 +43,13 @@ class MyEntryForm(forms.ModelForm): def clean_body(self): # Ensure this is valid XML body = self.cleaned_data["body"] - try: - ElementTree.fromstring("%s" % body) - except Exception as e: - raise forms.ValidationError(str(e)) + if not self.cleaned_data.get("use_markdown"): + from xml.etree import ElementTree + + try: + ElementTree.fromstring("%s" % body) + except Exception as e: + raise forms.ValidationError(str(e)) return body diff --git a/blog/feeds.py b/blog/feeds.py index be4106a4..75df9f14 100644 --- a/blog/feeds.py +++ b/blog/feeds.py @@ -1,6 +1,7 @@ from django.contrib.syndication.views import Feed from django.utils.dateformat import format as date_format from django.utils.feedgenerator import Atom1Feed +from django.utils.safestring import mark_safe from django.http import HttpResponse from blog.models import Entry, Blogmark, Quotation, Note @@ -63,7 +64,7 @@ def item_description(self, item): 'Subscribe to /atom/everything/ ' 'to get all of my posts, or take a look at my other subscription options.

' ) - return item.body + note + return mark_safe(str(item.rendered) + note) class Blogmarks(Base): diff --git a/blog/management/commands/validate_entries_xml.py b/blog/management/commands/validate_entries_xml.py index 6601a338..2ab8c086 100644 --- a/blog/management/commands/validate_entries_xml.py +++ b/blog/management/commands/validate_entries_xml.py @@ -1,5 +1,4 @@ from django.core.management.base import BaseCommand -from xml.etree import ElementTree from blog.models import Entry @@ -8,7 +7,11 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): for entry in Entry.objects.all(): + if entry.use_markdown: + continue try: + from xml.etree import ElementTree + ElementTree.fromstring("%s" % entry.body.encode("utf8")) except Exception as e: print(e) diff --git a/blog/migrations/0030_entry_use_markdown.py b/blog/migrations/0030_entry_use_markdown.py new file mode 100644 index 00000000..36098a49 --- /dev/null +++ b/blog/migrations/0030_entry_use_markdown.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.4 on 2025-10-04 19:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0029_blogmark_title"), + ] + + operations = [ + migrations.AddField( + model_name="entry", + name="use_markdown", + field=models.BooleanField( + default=False, + help_text='Images can use the img element - set width="..." for a specific width and use class="blogmark-image" to center and add a drop shadow.', + ), + ), + ] diff --git a/blog/models.py b/blog/models.py index 7ec1ef2e..c3fe0ad2 100644 --- a/blog/models.py +++ b/blog/models.py @@ -14,7 +14,7 @@ import arrow import datetime from markdown import markdown -from xml.etree import ElementTree +from bs4 import BeautifulSoup tag_re = re.compile("^[a-z0-9]+$") @@ -224,6 +224,10 @@ class Meta: class Entry(BaseModel): title = models.CharField(max_length=255) body = models.TextField() + use_markdown = models.BooleanField( + default=False, + help_text='Images can use the img element - set width="..." for a specific width and use class="blogmark-image" to center and add a drop shadow.', + ) tweet_html = models.TextField( blank=True, null=True, @@ -251,13 +255,13 @@ def previous_by_created(self): def images(self): """Extracts images from entry.body""" - et = ElementTree.fromstring("%s" % self.body) - return [i.attrib for i in et.findall(".//img")] + soup = BeautifulSoup(str(self.rendered), "html.parser") + return [dict(img.attrs) for img in soup.find_all("img")] def index_components(self): return { "A": self.title, - "C": strip_tags(self.body), + "C": strip_tags(str(self.rendered)), "B": " ".join(self.tags.values_list("tag", flat=True)), } @@ -286,7 +290,7 @@ def series_info(self): } def multi_paragraph(self): - return self.body.count(" 1 + return str(self.rendered).count(" 1 def __str__(self): return self.title @@ -294,6 +298,14 @@ def __str__(self): class Meta(BaseModel.Meta): verbose_name_plural = "Entries" + @property + def rendered(self): + if self.use_markdown: + rendered = markdown(self.body or "") + else: + rendered = self.body or "" + return mark_safe(rendered) + class LiveUpdate(models.Model): created = models.DateTimeField(auto_now_add=True) diff --git a/blog/templatetags/entry_tags.py b/blog/templatetags/entry_tags.py index a7b1c1b2..b560b59b 100644 --- a/blog/templatetags/entry_tags.py +++ b/blog/templatetags/entry_tags.py @@ -1,14 +1,11 @@ from django import template from django.utils.html import conditional_escape from django.utils.safestring import mark_safe -from xml.etree import ElementTree +from bs4 import BeautifulSoup, NavigableString, Comment import re import datetime register = template.Library() -entry_stripper = re.compile("^(.*?)$", re.DOTALL) - - @register.filter def xhtml(xhtml): return XhtmlString(xhtml, contains_markup=True) @@ -17,32 +14,38 @@ def xhtml(xhtml): class XhtmlString(object): def __init__(self, value, contains_markup=False): if isinstance(value, XhtmlString): - self.et = value.et + self.soup = value.soup else: + if value is None: + value = "" if not contains_markup: # Handle strings like "this & that" value = conditional_escape(value) - self.et = ElementTree.fromstring("%s" % value) + self.soup = BeautifulSoup(f"{value}", "html.parser") + self.et = self.soup.find("entry") def __str__(self): - m = entry_stripper.match(ElementTree.tostring(self.et, "unicode")) - if m: - return mark_safe(m.group(1)) - else: - return "" # If we end up with + if not self.et: + return "" + return mark_safe("".join(str(content) for content in self.et.contents)) @register.filter def resize_images_to_fit_width(value, arg): max_width = int(arg) x = XhtmlString(value) - for img in x.et.findall(".//img"): - width = int(img.get("width", 0)) - height = int(img.get("height", 0)) - if width > max_width: + if not x.et: + return x + for img in x.et.find_all("img"): + try: + width = int(img.get("width", 0)) + height = int(img.get("height", 0)) + except (TypeError, ValueError): + continue + if width > max_width and height: # Scale down - img.set("width", str(max_width)) - img.set("height", str(int(float(max_width) / width * height))) + img["width"] = str(max_width) + img["height"] = str(int(float(max_width) / width * height)) return x @@ -64,23 +67,27 @@ def split_cutoff(xhtml): @register.filter def remove_context_paragraph(xhtml): x = XhtmlString(xhtml) + if not x.et: + return x p = x.et.find("p") if p is None: return x - xhtml = ElementTree.tostring(p, "unicode") + xhtml = str(p) if xhtml.startswith("

My answer to") or xhtml.startswith( '

' ): - x.et.remove(p) + p.decompose() return x @register.filter def first_paragraph(xhtml): x = XhtmlString(xhtml) + if not x.et: + return mark_safe("

%s

" % xhtml) p = x.et.find("p") if p is not None: - return mark_safe(ElementTree.tostring(p, "unicode")) + return mark_safe(str(p)) else: return mark_safe("

%s

" % xhtml) @@ -104,9 +111,11 @@ def ends_with_punctuation(value): @register.filter def strip_p_ids(xhtml): x = XhtmlString(xhtml) - for p in x.et.findall(".//p"): - if "id" in p.attrib: - del p.attrib["id"] + if not x.et: + return x + for p in x.et.find_all("p"): + if "id" in p.attrs: + del p.attrs["id"] return x @@ -120,18 +129,26 @@ def break_up_long_words(xhtml, length): def do_break_long_words(et, length): - """Pass an ElementTree instance; breaks up long words in it""" - if et.text: - et.text = do_break_long_words_string(et.text, length) - for child in et: - do_break_long_words(child, length) - if et.tail: - et.tail = do_break_long_words_string(et.tail, length) + """Pass a BeautifulSoup Tag instance; breaks up long words in it""" + if et is None: + return + for node in list(_iter_text_nodes(et)): + new_text = do_break_long_words_string(str(node), length) + if new_text != str(node): + node.replace_with(new_text) whitespace_re = re.compile(r"(\s+)") +def _iter_text_nodes(tag): + if tag is None: + return [] + for node in tag.descendants: + if isinstance(node, NavigableString) and not isinstance(node, Comment): + yield node + + def do_break_long_words_string(s, length): bits = whitespace_re.split(s) for i, bit in enumerate(bits): @@ -167,15 +184,15 @@ def strip_wrapping_p(xhtml): def do_typography(et): - # Designed to be called recursively on ElementTree objects - if et.tag not in ("pre", "code"): - # Don't do et.text or children for those tags; just do et.tail - if et.text: - et.text = do_typography_string(et.text) - for child in et: - do_typography(child) - if et.tail: - et.tail = do_typography_string(et.tail) + if et is None: + return + for node in list(_iter_text_nodes(et)): + parent = node.parent + if parent and parent.name in ("pre", "code"): + continue + new_text = do_typography_string(str(node)) + if new_text != str(node): + node.replace_with(new_text) LEFT_DOUBLE_QUOTATION_MARK = "\u201c" diff --git a/blog/tests.py b/blog/tests.py index 8425ed78..7f85ff77 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -113,6 +113,49 @@ def test_markup(self): """.strip(), ) + def test_markdown_entry_renders_everywhere(self): + tag = Tag.objects.create(tag="markdown-test") + entry = EntryFactory( + title="Markdown Entry", + body="This is **markdown** text.", + use_markdown=True, + ) + entry.tags.add(tag) + + year = entry.created.year + month = entry.created.strftime("%b") + day = entry.created.day + + paths = { + "/": True, + f"/{year}/": False, + f"/{year}/{month}/": True, + f"/{year}/{month}/{day}/": True, + entry.get_absolute_url(): True, + f"/tags/{tag.tag}/": True, + f"/search/?tag={tag.tag}": True, + } + + for path, should_have_markup in paths.items(): + response = self.client.get(path) + self.assertEqual(response.status_code, 200, msg=path) + if should_have_markup and "markdown" not in response.content.decode(): + self.fail(f"{path} missing rendered markdown") + + entries_feed = self.client.get("/atom/entries/") + self.assertEqual(entries_feed.status_code, 200) + self.assertIn( + "<strong>markdown</strong>", + entries_feed.content.decode(), + ) + + everything_feed = self.client.get("/atom/everything/") + self.assertEqual(everything_feed.status_code, 200) + self.assertIn( + "<strong>markdown</strong>", + everything_feed.content.decode(), + ) + def test_update_blogmark_runs_commit_hooks(self): # This was throwing errors on upgrade Django 2.2 to 2.2.1 blogmark = BlogmarkFactory() diff --git a/templates/entry.html b/templates/entry.html index 99010d2a..b76622d9 100644 --- a/templates/entry.html +++ b/templates/entry.html @@ -9,7 +9,7 @@ {% endblock %} {% block card_title %}{{ entry.title|typography }}{% endblock %} -{% block card_description %}{{ entry.body|xhtml|remove_context_paragraph|typography|xhtml2html|striptags|truncatewords:30|force_escape }}{% endblock %} +{% block card_description %}{{ entry.rendered|xhtml|remove_context_paragraph|typography|xhtml2html|striptags|truncatewords:30|force_escape }}{% endblock %} {% block item_content %}
@@ -18,7 +18,7 @@

{{ entry.title|typography }}

{% include "_draft_warning.html" %} -{{ entry.body|xhtml|resize_images_to_fit_width:"450"|typography|xhtml2html }} +{{ entry.rendered|xhtml|resize_images_to_fit_width:"450"|typography|xhtml2html }} {% if updates %}
diff --git a/templates/feeds/everything.html b/templates/feeds/everything.html index d55465e8..918e5ff2 100644 --- a/templates/feeds/everything.html +++ b/templates/feeds/everything.html @@ -1,5 +1,5 @@ {% if obj.is_entry %} - {{ obj.body|safe }} + {{ obj.rendered|safe }} {% if obj.tags.count %}

Tags: {% for tag in obj.tags.all %}{{ tag.tag }}{% if not forloop.last %}, {% endif %}{% endfor %}

{% endif %} diff --git a/templates/includes/blog_mixed_list.html b/templates/includes/blog_mixed_list.html index 5563a8db..98a04b83 100644 --- a/templates/includes/blog_mixed_list.html +++ b/templates/includes/blog_mixed_list.html @@ -20,7 +20,7 @@

{{ item.obj.title|t

{% endif %}

- {{ item.obj.body|split_cutoff|xhtml|remove_context_paragraph|first_paragraph|typography|xhtml2html }} + {{ item.obj.rendered|split_cutoff|xhtml|remove_context_paragraph|first_paragraph|typography|xhtml2html }} {% if item.obj.multi_paragraph %}[... {{ item.obj.body|wordcount|intcomma }} word{{ item.obj.body|wordcount|pluralize }}]{% endif %}