diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py new file mode 100644 index 0000000000..8ad46f675a --- /dev/null +++ b/blog/templatetags/blog_extras.py @@ -0,0 +1,48 @@ +from html.parser import HTMLParser + +from django import template +from django.utils.html import format_html + +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..e148805a3c --- /dev/null +++ b/blog/tests/test_lazy_loading.py @@ -0,0 +1,28 @@ +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..7aeed7efd3 --- /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..e23253360d --- /dev/null +++ b/djangoproject/templates/foundation/tests/test_templates.py @@ -0,0 +1,23 @@ +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='

' + ) + 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)