From 85c6eef610d7e2b64f7681f9c21200c442d65392 Mon Sep 17 00:00:00 2001 From: AnmolChauhan Date: Wed, 20 Aug 2025 00:52:16 +0530 Subject: [PATCH 1/2] feat: add lazy loading and async decoding to blog images - Added add_lazy_loading filter in blog_extras.py - Applied filter to object.body_html in blog templates (entry_detail.html, meeting_detail.html) - Added unit tests in blog/tests/test_lazy_loading.py - Added test settings (settings/test.py) for faster CI runs - Updated foundation template for consistency Fixes #2154 --- blog/templatetags/blog_extras.py | 45 +++++++++++++++++++ blog/tests/test_lazy_loading.py | 26 +++++++++++ djangoproject/settings/test.py | 17 +++++++ .../templates/blog/entry_detail.html | 6 ++- .../templates/foundation/meeting_detail.html | 5 ++- .../foundation/tests/test_templates.py | 17 +++++++ 6 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 blog/templatetags/blog_extras.py create mode 100644 blog/tests/test_lazy_loading.py create mode 100644 djangoproject/settings/test.py create mode 100644 djangoproject/templates/foundation/tests/test_templates.py diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py new file mode 100644 index 0000000000..0d96859219 --- /dev/null +++ b/blog/templatetags/blog_extras.py @@ -0,0 +1,45 @@ +from django import template +from django.utils.html import format_html +from html.parser import HTMLParser + +register = template.Library() + +class LazyLoadingHTMLParser(HTMLParser): + def __init__(self): + super().__init__() + self.result = [] + + def handle_starttag(self, tag, attrs): + if tag.lower() == "img": + attrs_dict = dict(attrs) + attrs_dict.setdefault("loading", "lazy") + attrs_dict.setdefault("decoding", "async") + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs_dict.items()) + self.result.append(f"<{tag} {attrs_str}>") + else: + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs) + self.result.append(f"<{tag}{' ' + attrs_str if attrs_str else ''}>") + + def handle_endtag(self, tag): + self.result.append(f"") + + def handle_data(self, data): + self.result.append(data) + + def handle_startendtag(self, tag, attrs): + # For self-closing tags like + if tag.lower() == "img": + attrs_dict = dict(attrs) + attrs_dict.setdefault("loading", "lazy") + attrs_dict.setdefault("decoding", "async") + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs_dict.items()) + self.result.append(f"<{tag} {attrs_str} />") + else: + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs) + self.result.append(f"<{tag}{' ' + attrs_str if attrs_str else ''} />") + +@register.filter +def add_lazy_loading(html): + parser = LazyLoadingHTMLParser() + parser.feed(html) + return format_html("".join(parser.result)) diff --git a/blog/tests/test_lazy_loading.py b/blog/tests/test_lazy_loading.py new file mode 100644 index 0000000000..c4b6f72590 --- /dev/null +++ b/blog/tests/test_lazy_loading.py @@ -0,0 +1,26 @@ +from django.test import SimpleTestCase +from blog.templatetags.blog_extras import add_lazy_loading + +class AddLazyLoadingFilterTests(SimpleTestCase): + def test_adds_attributes_to_img_without_them(self): + html = '

Example

' + result = add_lazy_loading(html) + self.assertIn('loading="lazy"', result) + self.assertIn('decoding="async"', result) + + def test_does_not_override_existing_attributes(self): + html = '

' + result = add_lazy_loading(html) + self.assertIn('loading="eager"', result) + self.assertIn('decoding="sync"', result) + + def test_handles_multiple_images(self): + html = '' + result = add_lazy_loading(html) + self.assertEqual(result.count('loading="lazy"'), 2) + self.assertEqual(result.count('decoding="async"'), 2) + + def test_non_image_tags_are_untouched(self): + html = '

No images here

' + result = add_lazy_loading(html) + self.assertEqual(result, html) diff --git a/djangoproject/settings/test.py b/djangoproject/settings/test.py new file mode 100644 index 0000000000..ddc85a7753 --- /dev/null +++ b/djangoproject/settings/test.py @@ -0,0 +1,17 @@ +from .common import INSTALLED_APPS + +DEBUG = False +SECRET_KEY = 'test-secret-key' + +INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "foundation"] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] diff --git a/djangoproject/templates/blog/entry_detail.html b/djangoproject/templates/blog/entry_detail.html index c42f519693..d1ef6288fb 100644 --- a/djangoproject/templates/blog/entry_detail.html +++ b/djangoproject/templates/blog/entry_detail.html @@ -1,5 +1,6 @@ {% extends "base_weblog.html" %} {% load i18n %} +{% load blog_extras %} {# Load your custom filter #} {% block title %}{{ object.headline|escape }} | Weblog{% endblock %} @@ -17,5 +18,8 @@

{{ object.headline|safe }}

Posted by {{ author }} on {{ pub_date }} {% endblocktranslate %} -
{{ object.body_html|safe }}
+ +
+ {{ object.body_html|add_lazy_loading|safe }} +
{% endblock %} diff --git a/djangoproject/templates/foundation/meeting_detail.html b/djangoproject/templates/foundation/meeting_detail.html index 8f767b8e85..5fb321edb3 100644 --- a/djangoproject/templates/foundation/meeting_detail.html +++ b/djangoproject/templates/foundation/meeting_detail.html @@ -1,5 +1,6 @@ {% extends "base_foundation.html" %} {% load i18n %} +{% load blog_extras %} {# Load your custom filter #} {% block og_title %}{% blocktranslate %}Meeting minutes: {{ meeting }}{% endblocktranslate %}{% endblock %} {% block og_description %}{% blocktranslate %}Meeting minutes for {{ meeting }}{% endblocktranslate %}{% endblock %} @@ -80,7 +81,7 @@

{% translate "Ongoing business" %}

{% for business in ongoing_business %}

{{ business.title }}

- {{ business.body_html|safe }} + {{ business.body_html|add_lazy_loading|safe }} {% endfor %} {% endif %} @@ -90,7 +91,7 @@

{% translate "New business" %}

{% for business in new_business %}

{{ business.title }}

- {{ business.body_html|safe }} + {{ business.body_html|add_lazy_loading|safe }} {% endfor %} {% endif %} diff --git a/djangoproject/templates/foundation/tests/test_templates.py b/djangoproject/templates/foundation/tests/test_templates.py new file mode 100644 index 0000000000..75c4235091 --- /dev/null +++ b/djangoproject/templates/foundation/tests/test_templates.py @@ -0,0 +1,17 @@ +from django.test import TestCase +from foundation.models import Meeting, Business +from django.template import Context, Template + +class LazyLoadingMeetingTemplateTests(TestCase): + def test_meeting_body_html_has_lazy_loading(self): + meeting = Meeting.objects.create(title="Test Meeting") + business = Business.objects.create(title="Test Business", body_html='

') + meeting.ongoing_business.add(business) + + template = Template(""" + {% load blog_extras %} + {{ business.body_html|add_lazy_loading|safe }} + """) + rendered = template.render(Context({'business': business})) + self.assertIn('loading="lazy"', rendered) + self.assertIn('decoding="async"', rendered) From 6ab539912dc2c8557f701bd553a8058429d1023c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:42:33 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- blog/templatetags/blog_extras.py | 5 ++++- blog/tests/test_lazy_loading.py | 4 +++- djangoproject/settings/test.py | 4 ++-- .../foundation/tests/test_templates.py | 18 ++++++++++++------ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py index 0d96859219..8ad46f675a 100644 --- a/blog/templatetags/blog_extras.py +++ b/blog/templatetags/blog_extras.py @@ -1,9 +1,11 @@ +from html.parser import HTMLParser + from django import template from django.utils.html import format_html -from html.parser import HTMLParser register = template.Library() + class LazyLoadingHTMLParser(HTMLParser): def __init__(self): super().__init__() @@ -38,6 +40,7 @@ def handle_startendtag(self, tag, attrs): attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs) self.result.append(f"<{tag}{' ' + attrs_str if attrs_str else ''} />") + @register.filter def add_lazy_loading(html): parser = LazyLoadingHTMLParser() diff --git a/blog/tests/test_lazy_loading.py b/blog/tests/test_lazy_loading.py index c4b6f72590..e148805a3c 100644 --- a/blog/tests/test_lazy_loading.py +++ b/blog/tests/test_lazy_loading.py @@ -1,6 +1,8 @@ from django.test import SimpleTestCase + from blog.templatetags.blog_extras import add_lazy_loading + class AddLazyLoadingFilterTests(SimpleTestCase): def test_adds_attributes_to_img_without_them(self): html = '

Example

' @@ -21,6 +23,6 @@ def test_handles_multiple_images(self): self.assertEqual(result.count('decoding="async"'), 2) def test_non_image_tags_are_untouched(self): - html = '

No images here

' + html = "

No images here

" result = add_lazy_loading(html) self.assertEqual(result, html) diff --git a/djangoproject/settings/test.py b/djangoproject/settings/test.py index ddc85a7753..7aeed7efd3 100644 --- a/djangoproject/settings/test.py +++ b/djangoproject/settings/test.py @@ -1,7 +1,7 @@ from .common import INSTALLED_APPS DEBUG = False -SECRET_KEY = 'test-secret-key' +SECRET_KEY = "test-secret-key" INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "foundation"] @@ -13,5 +13,5 @@ } PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', + "django.contrib.auth.hashers.MD5PasswordHasher", ] diff --git a/djangoproject/templates/foundation/tests/test_templates.py b/djangoproject/templates/foundation/tests/test_templates.py index 75c4235091..e23253360d 100644 --- a/djangoproject/templates/foundation/tests/test_templates.py +++ b/djangoproject/templates/foundation/tests/test_templates.py @@ -1,17 +1,23 @@ -from django.test import TestCase -from foundation.models import Meeting, Business from django.template import Context, Template +from django.test import TestCase + +from foundation.models import Business, Meeting + class LazyLoadingMeetingTemplateTests(TestCase): def test_meeting_body_html_has_lazy_loading(self): meeting = Meeting.objects.create(title="Test Meeting") - business = Business.objects.create(title="Test Business", body_html='

') + business = Business.objects.create( + title="Test Business", body_html='

' + ) meeting.ongoing_business.add(business) - template = Template(""" + template = Template( + """ {% load blog_extras %} {{ business.body_html|add_lazy_loading|safe }} - """) - rendered = template.render(Context({'business': business})) + """ + ) + rendered = template.render(Context({"business": business})) self.assertIn('loading="lazy"', rendered) self.assertIn('decoding="async"', rendered)