From e07b2ed191cbabca2871333d439f29e385fafd9f Mon Sep 17 00:00:00 2001 From: Philippe Gregoire Date: Thu, 30 Oct 2025 16:25:55 +0100 Subject: [PATCH 1/5] #2154 Lazy-loading for images in blog content --- blog/models.py | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/blog/models.py b/blog/models.py index bceb067d1..120a90d43 100644 --- a/blog/models.py +++ b/blog/models.py @@ -12,8 +12,13 @@ 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, Markdown from markdown.extensions.toc import TocExtension, slugify as _md_title_slugify +import markdown.treeprocessors +import xml.etree.ElementTree as etree BLOG_DOCUTILS_SETTINGS = { "doctitle_xform": False, @@ -37,6 +42,36 @@ def published(self): 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(markdown.treeprocessors.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" @@ -51,20 +86,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): From a08b729b9d96f95f0b85091b6cabd69526eb6317 Mon Sep 17 00:00:00 2001 From: Philippe Gregoire Date: Thu, 30 Oct 2025 16:56:35 +0100 Subject: [PATCH 2/5] #2154 Test Lazy-loading for images in blog content --- blog/tests.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/blog/tests.py b/blog/tests.py index c345fda56..02dfe6da0 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -130,6 +130,30 @@ def test_header_base_level_markdown(self): ) self.assertHTMLEqual(entry.body_html, '

test

') + def test_image_lazy_loader_markdown(self): + entry = Entry.objects.create( + pub_date=self.now, + slug="a", + body="# this is it![dilbert_alt](/m/blog/images/2025/10/Dilbert.gif)", + content_format=ContentFormat.MARKDOWN, + ) + self.assertHTMLEqual( + entry.body_html, + '

this is it

dilbert_alt

', + ) + + 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, + 'dilbert_alt', + ) + def test_pub_date_localized(self): entry = Entry(pub_date=date(2005, 7, 21)) self.assertEqual(entry.pub_date_localized, "July 21, 2005") From 58b16ebb4a73219060255413edb288812bd69b54 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:08:49 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- blog/models.py | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/blog/models.py b/blog/models.py index 120a90d43..10611b963 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,5 +1,7 @@ +import xml.etree.ElementTree as etree from urllib.parse import urlparse +import markdown.treeprocessors from django.conf import settings from django.core.cache import caches from django.db import models @@ -14,11 +16,8 @@ from docutils.core import publish_parts from docutils.nodes import document from docutils.writers.html4css1 import HTMLTranslator, Writer - -from markdown import markdown, Markdown +from markdown import Markdown, markdown from markdown.extensions.toc import TocExtension, slugify as _md_title_slugify -import markdown.treeprocessors -import xml.etree.ElementTree as etree BLOG_DOCUTILS_SETTINGS = { "doctitle_xform": False, @@ -42,19 +41,25 @@ def published(self): def active(self): return self.filter(is_active=True) -_IMG_LAZY_ATTRIBUTES = {"loading":"lazy"} + +_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: + + 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): + 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": + if tagname == "img": attributes.update(self._img_attributes) - return super().emptytag(node,tagname,suffix,**attributes) + return super().emptytag(node, tagname, suffix, **attributes) + class LazyImageTreeprocessor(markdown.treeprocessors.Treeprocessor): """ @@ -63,13 +68,16 @@ class LazyImageTreeprocessor(markdown.treeprocessors.Treeprocessor): 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: + + 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 + 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'): + for img_elem in root.iter("img"): img_elem.attrib.update(self._img_attributes) @@ -86,8 +94,8 @@ 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 + writer = Writer() + writer.translator_class = LazyImageHTMLTranslator return publish_parts( source=source, @@ -102,7 +110,7 @@ def to_html(cls, fmt, source): TocExtension(baselevel=3, slugify=_md_slugify), ], ) - md.treeprocessors.register(LazyImageTreeprocessor(),"lazyimage",0.3) + md.treeprocessors.register(LazyImageTreeprocessor(), "lazyimage", 0.3) return md.convert(source) raise ValueError(f"Unsupported format {fmt}") From e181bb359f0b14de56aaeafeee74fe7f27922fda Mon Sep 17 00:00:00 2001 From: Philippe Gregoire Date: Thu, 30 Oct 2025 18:49:26 +0100 Subject: [PATCH 4/5] #2154 Fix formatting Lazy-loading for images in blog content --- blog/tests.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/blog/tests.py b/blog/tests.py index 02dfe6da0..ca86f0dbd 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -139,7 +139,9 @@ def test_image_lazy_loader_markdown(self): ) self.assertHTMLEqual( entry.body_html, - '

this is it

dilbert_alt

', + '

this is it

' + '

dilbert_alt

', ) def test_image_lazy_loader_reST(self): @@ -151,7 +153,8 @@ def test_image_lazy_loader_reST(self): ) self.assertHTMLEqual( entry.body_html, - 'dilbert_alt', + 'dilbert_alt', ) def test_pub_date_localized(self): From f7239e9f0a1dae03c839e24f460fa8b9233ec0b6 Mon Sep 17 00:00:00 2001 From: Philippe Gregoire Date: Thu, 30 Oct 2025 19:38:16 +0100 Subject: [PATCH 5/5] #2154 Fix tests: Lazy-loading for images in blog content --- blog/models.py | 6 +++--- blog/tests.py | 51 +++++++++++++++++++++++++------------------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/blog/models.py b/blog/models.py index 10611b963..bc8844fac 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as etree from urllib.parse import urlparse -import markdown.treeprocessors from django.conf import settings from django.core.cache import caches from django.db import models @@ -16,8 +15,9 @@ from docutils.core import publish_parts from docutils.nodes import document from docutils.writers.html4css1 import HTMLTranslator, Writer -from markdown import Markdown, markdown +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, @@ -61,7 +61,7 @@ def emptytag(self, node, tagname, suffix="\n", **attributes): return super().emptytag(node, tagname, suffix, **attributes) -class LazyImageTreeprocessor(markdown.treeprocessors.Treeprocessor): +class LazyImageTreeprocessor(Treeprocessor): """ `Treeprocessor`s are run on the `ElementTree` object before serialization. diff --git a/blog/tests.py b/blog/tests.py index ca86f0dbd..dece5b19b 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -134,7 +134,7 @@ def test_image_lazy_loader_markdown(self): entry = Entry.objects.create( pub_date=self.now, slug="a", - body="# this is it![dilbert_alt](/m/blog/images/2025/10/Dilbert.gif)", + body="# this is it\n![dilbert_alt](/m/blog/images/2025/10/Dilbert.gif)", content_format=ContentFormat.MARKDOWN, ) self.assertHTMLEqual( @@ -608,33 +608,34 @@ def test_full_url(self): ) def test_alt_text_html_escape(self): + common_img_tag = 'te"st'), (ContentFormat.HTML, "te", 'te<st>'), - (ContentFormat.MARKDOWN, 'te"st', 'te"st'), - (ContentFormat.MARKDOWN, "te[st]", 'te[st]'), - (ContentFormat.MARKDOWN, "te{st}", 'te{st}'), - (ContentFormat.MARKDOWN, "te", 'te<st>'), - (ContentFormat.MARKDOWN, "test*", 'test*'), - (ContentFormat.MARKDOWN, "test_", 'test_'), - (ContentFormat.MARKDOWN, "test`", 'test`'), - (ContentFormat.MARKDOWN, "test+", 'test+'), - (ContentFormat.MARKDOWN, "test-", 'test-'), - (ContentFormat.MARKDOWN, "test.", 'test.'), - (ContentFormat.MARKDOWN, "test!", 'test!'), - (ContentFormat.MARKDOWN, "te\nst", 'te\nst'), - (ContentFormat.REST, 'te"st', 'te"st'), - (ContentFormat.REST, "te[st]", 'te[st]'), - (ContentFormat.REST, "te{st}", 'te{st}'), - (ContentFormat.REST, "te", 'te<st>'), - (ContentFormat.REST, "te:st", 'te:st'), - (ContentFormat.REST, "test*", 'test*'), - (ContentFormat.REST, "test_", 'test_'), - (ContentFormat.REST, "test`", 'test`'), - (ContentFormat.REST, "test+", 'test+'), - (ContentFormat.REST, "test-", 'test-'), - (ContentFormat.REST, "test.", 'test.'), - (ContentFormat.REST, "test!", 'test!'), + (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{st}">'), + (ContentFormat.MARKDOWN, "te", common_img_tag + 'alt="te<st>">'), + (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"st">'), + (ContentFormat.REST, "te[st]", common_img_tag + 'alt="te[st]">'), + (ContentFormat.REST, "te{st}", common_img_tag + 'alt="te{st}">'), + (ContentFormat.REST, "te", common_img_tag + 'alt="te<st>">'), + (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