Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 51 additions & 4 deletions blog/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import xml.etree.ElementTree as etree
from urllib.parse import urlparse

from django.conf import settings
Expand All @@ -12,8 +13,11 @@
from django.utils.translation import gettext_lazy as _
from django_hosts.resolvers import get_host, reverse, reverse_host
from docutils.core import publish_parts
from markdown import markdown
from docutils.nodes import document
from docutils.writers.html4css1 import HTMLTranslator, Writer
from markdown import Markdown
from markdown.extensions.toc import TocExtension, slugify as _md_title_slugify
from markdown.treeprocessors import Treeprocessor

BLOG_DOCUTILS_SETTINGS = {
"doctitle_xform": False,
Expand All @@ -38,6 +42,45 @@ def active(self):
return self.filter(is_active=True)


_IMG_LAZY_ATTRIBUTES = {"loading": "lazy"}


class LazyImageHTMLTranslator(HTMLTranslator):
"""Alter the img tags to include the lazy attribute."""

def __init__(
self, document: document, img_attributes: dict[str, str] | None = None
) -> None:
super().__init__(document)
self._img_attributes = img_attributes or _IMG_LAZY_ATTRIBUTES

def emptytag(self, node, tagname, suffix="\n", **attributes):
"""Construct and return an XML-compatible empty tag."""
if tagname == "img":
attributes.update(self._img_attributes)
return super().emptytag(node, tagname, suffix, **attributes)


class LazyImageTreeprocessor(Treeprocessor):
"""
`Treeprocessor`s are run on the `ElementTree` object before serialization.

This processor will add loading=lazy attribute on img tags

"""

def __init__(
self, img_attributes: dict[str, str] | None = None, md: Markdown | None = None
) -> None:
super().__init__(md)
self._img_attributes = img_attributes or _IMG_LAZY_ATTRIBUTES

def run(self, root: etree.Element) -> etree.Element | None:
"""Alter img tags with the supplemental attributes."""
for img_elem in root.iter("img"):
img_elem.attrib.update(self._img_attributes)


class ContentFormat(models.TextChoices):
REST = "reST", "reStructuredText"
HTML = "html", "Raw HTML"
Expand All @@ -51,20 +94,24 @@ def to_html(cls, fmt, source):
if not fmt or fmt == cls.HTML:
return source
if fmt == cls.REST:
writer = Writer()
writer.translator_class = LazyImageHTMLTranslator

return publish_parts(
source=source,
writer_name="html",
writer=writer,
settings_overrides=BLOG_DOCUTILS_SETTINGS,
)["fragment"]
if fmt == cls.MARKDOWN:
return markdown(
source,
md = Markdown(
output_format="html",
extensions=[
# baselevel matches `initial_header_level` from BLOG_DOCUTILS_SETTINGS
TocExtension(baselevel=3, slugify=_md_slugify),
],
)
md.treeprocessors.register(LazyImageTreeprocessor(), "lazyimage", 0.3)
return md.convert(source)
raise ValueError(f"Unsupported format {fmt}")

def img(self, url, alt_text):
Expand Down
76 changes: 52 additions & 24 deletions blog/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ def test_header_base_level_markdown(self):
)
self.assertHTMLEqual(entry.body_html, '<h3 id="s-test">test</h3>')

def test_image_lazy_loader_markdown(self):
entry = Entry.objects.create(
pub_date=self.now,
slug="a",
body="# this is it\n![dilbert_alt](/m/blog/images/2025/10/Dilbert.gif)",
content_format=ContentFormat.MARKDOWN,
)
self.assertHTMLEqual(
entry.body_html,
'<h3 id="s-this-is-it">this is it</h3>'
'<p><img alt="dilbert_alt" loading="lazy"'
' src="/m/blog/images/2025/10/Dilbert.gif"></p>',
)

def test_image_lazy_loader_reST(self):
entry = Entry.objects.create(
pub_date=self.now,
slug="a",
body=".. image:: /m/blog/images/2025/10/Dilbert.gif\n :alt: dilbert_alt",
content_format=ContentFormat.REST,
)
self.assertHTMLEqual(
entry.body_html,
'<img alt="dilbert_alt" loading="lazy"'
' src="/m/blog/images/2025/10/Dilbert.gif">',
)

def test_pub_date_localized(self):
entry = Entry(pub_date=date(2005, 7, 21))
self.assertEqual(entry.pub_date_localized, "July 21, 2005")
Expand Down Expand Up @@ -581,33 +608,34 @@ def test_full_url(self):
)

def test_alt_text_html_escape(self):
common_img_tag = '<img src="." loading="lazy" '
testdata = [
(ContentFormat.HTML, 'te"st', '<img src="." alt="te&quot;st">'),
(ContentFormat.HTML, "te<st>", '<img src="." alt="te&lt;st&gt;">'),
(ContentFormat.MARKDOWN, 'te"st', '<img src="." alt="te&quot;st">'),
(ContentFormat.MARKDOWN, "te[st]", '<img src="." alt="te[st]">'),
(ContentFormat.MARKDOWN, "te{st}", '<img src="." alt="te{st}">'),
(ContentFormat.MARKDOWN, "te<st>", '<img src="." alt="te&lt;st&gt;">'),
(ContentFormat.MARKDOWN, "test*", '<img src="." alt="test*">'),
(ContentFormat.MARKDOWN, "test_", '<img src="." alt="test_">'),
(ContentFormat.MARKDOWN, "test`", '<img src="." alt="test`">'),
(ContentFormat.MARKDOWN, "test+", '<img src="." alt="test+">'),
(ContentFormat.MARKDOWN, "test-", '<img src="." alt="test-">'),
(ContentFormat.MARKDOWN, "test.", '<img src="." alt="test.">'),
(ContentFormat.MARKDOWN, "test!", '<img src="." alt="test!">'),
(ContentFormat.MARKDOWN, "te\nst", '<img src="." alt="te\nst">'),
(ContentFormat.REST, 'te"st', '<img src="." alt="te&quot;st">'),
(ContentFormat.REST, "te[st]", '<img src="." alt="te[st]">'),
(ContentFormat.REST, "te{st}", '<img src="." alt="te{st}">'),
(ContentFormat.REST, "te<st>", '<img src="." alt="te&lt;st&gt;">'),
(ContentFormat.REST, "te:st", '<img src="." alt="te:st">'),
(ContentFormat.REST, "test*", '<img src="." alt="test*">'),
(ContentFormat.REST, "test_", '<img src="." alt="test_">'),
(ContentFormat.REST, "test`", '<img src="." alt="test`">'),
(ContentFormat.REST, "test+", '<img src="." alt="test+">'),
(ContentFormat.REST, "test-", '<img src="." alt="test-">'),
(ContentFormat.REST, "test.", '<img src="." alt="test.">'),
(ContentFormat.REST, "test!", '<img src="." alt="test!">'),
(ContentFormat.MARKDOWN, 'te"st', common_img_tag + 'alt="te&quot;st">'),
(ContentFormat.MARKDOWN, "te[st]", common_img_tag + 'alt="te[st]">'),
(ContentFormat.MARKDOWN, "te{st}", common_img_tag + 'alt="te{st}">'),
(ContentFormat.MARKDOWN, "te<st>", common_img_tag + 'alt="te&lt;st&gt;">'),
(ContentFormat.MARKDOWN, "test*", common_img_tag + 'alt="test*">'),
(ContentFormat.MARKDOWN, "test_", common_img_tag + 'alt="test_">'),
(ContentFormat.MARKDOWN, "test`", common_img_tag + 'alt="test`">'),
(ContentFormat.MARKDOWN, "test+", common_img_tag + 'alt="test+">'),
(ContentFormat.MARKDOWN, "test-", common_img_tag + 'alt="test-">'),
(ContentFormat.MARKDOWN, "test.", common_img_tag + 'alt="test.">'),
(ContentFormat.MARKDOWN, "test!", common_img_tag + 'alt="test!">'),
(ContentFormat.MARKDOWN, "te\nst", common_img_tag + 'alt="te\nst">'),
(ContentFormat.REST, 'te"st', common_img_tag + 'alt="te&quot;st">'),
(ContentFormat.REST, "te[st]", common_img_tag + 'alt="te[st]">'),
(ContentFormat.REST, "te{st}", common_img_tag + 'alt="te{st}">'),
(ContentFormat.REST, "te<st>", common_img_tag + 'alt="te&lt;st&gt;">'),
(ContentFormat.REST, "te:st", common_img_tag + 'alt="te:st">'),
(ContentFormat.REST, "test*", common_img_tag + 'alt="test*">'),
(ContentFormat.REST, "test_", common_img_tag + 'alt="test_">'),
(ContentFormat.REST, "test`", common_img_tag + 'alt="test`">'),
(ContentFormat.REST, "test+", common_img_tag + 'alt="test+">'),
(ContentFormat.REST, "test-", common_img_tag + 'alt="test-">'),
(ContentFormat.REST, "test.", common_img_tag + 'alt="test.">'),
(ContentFormat.REST, "test!", common_img_tag + 'alt="test!">'),
]
for cf, alt_text, expected in testdata:
# RST doesn't like an empty src, so we use . instead
Expand Down