Skip to content

Commit 85c6eef

Browse files
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
1 parent 6fcf41e commit 85c6eef

File tree

6 files changed

+113
-3
lines changed

6 files changed

+113
-3
lines changed

blog/templatetags/blog_extras.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from django import template
2+
from django.utils.html import format_html
3+
from html.parser import HTMLParser
4+
5+
register = template.Library()
6+
7+
class LazyLoadingHTMLParser(HTMLParser):
8+
def __init__(self):
9+
super().__init__()
10+
self.result = []
11+
12+
def handle_starttag(self, tag, attrs):
13+
if tag.lower() == "img":
14+
attrs_dict = dict(attrs)
15+
attrs_dict.setdefault("loading", "lazy")
16+
attrs_dict.setdefault("decoding", "async")
17+
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs_dict.items())
18+
self.result.append(f"<{tag} {attrs_str}>")
19+
else:
20+
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs)
21+
self.result.append(f"<{tag}{' ' + attrs_str if attrs_str else ''}>")
22+
23+
def handle_endtag(self, tag):
24+
self.result.append(f"</{tag}>")
25+
26+
def handle_data(self, data):
27+
self.result.append(data)
28+
29+
def handle_startendtag(self, tag, attrs):
30+
# For self-closing tags like <img />
31+
if tag.lower() == "img":
32+
attrs_dict = dict(attrs)
33+
attrs_dict.setdefault("loading", "lazy")
34+
attrs_dict.setdefault("decoding", "async")
35+
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs_dict.items())
36+
self.result.append(f"<{tag} {attrs_str} />")
37+
else:
38+
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs)
39+
self.result.append(f"<{tag}{' ' + attrs_str if attrs_str else ''} />")
40+
41+
@register.filter
42+
def add_lazy_loading(html):
43+
parser = LazyLoadingHTMLParser()
44+
parser.feed(html)
45+
return format_html("".join(parser.result))

blog/tests/test_lazy_loading.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.test import SimpleTestCase
2+
from blog.templatetags.blog_extras import add_lazy_loading
3+
4+
class AddLazyLoadingFilterTests(SimpleTestCase):
5+
def test_adds_attributes_to_img_without_them(self):
6+
html = '<p>Example <img src="example.jpg"></p>'
7+
result = add_lazy_loading(html)
8+
self.assertIn('loading="lazy"', result)
9+
self.assertIn('decoding="async"', result)
10+
11+
def test_does_not_override_existing_attributes(self):
12+
html = '<p><img src="test.jpg" loading="eager" decoding="sync"></p>'
13+
result = add_lazy_loading(html)
14+
self.assertIn('loading="eager"', result)
15+
self.assertIn('decoding="sync"', result)
16+
17+
def test_handles_multiple_images(self):
18+
html = '<img src="a.jpg"><img src="b.jpg">'
19+
result = add_lazy_loading(html)
20+
self.assertEqual(result.count('loading="lazy"'), 2)
21+
self.assertEqual(result.count('decoding="async"'), 2)
22+
23+
def test_non_image_tags_are_untouched(self):
24+
html = '<p>No images here</p>'
25+
result = add_lazy_loading(html)
26+
self.assertEqual(result, html)

djangoproject/settings/test.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .common import INSTALLED_APPS
2+
3+
DEBUG = False
4+
SECRET_KEY = 'test-secret-key'
5+
6+
INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "foundation"]
7+
8+
DATABASES = {
9+
"default": {
10+
"ENGINE": "django.db.backends.sqlite3",
11+
"NAME": ":memory:",
12+
}
13+
}
14+
15+
PASSWORD_HASHERS = [
16+
'django.contrib.auth.hashers.MD5PasswordHasher',
17+
]

djangoproject/templates/blog/entry_detail.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{% extends "base_weblog.html" %}
22
{% load i18n %}
3+
{% load blog_extras %} {# Load your custom filter #}
34

45
{% block title %}{{ object.headline|escape }} | Weblog{% endblock %}
56

@@ -17,5 +18,8 @@ <h1>{{ object.headline|safe }}</h1>
1718
Posted by <strong>{{ author }}</strong> on {{ pub_date }}
1819
{% endblocktranslate %}
1920
</span>
20-
<div class="blog-entry-body">{{ object.body_html|safe }}</div>
21+
22+
<div class="blog-entry-body">
23+
{{ object.body_html|add_lazy_loading|safe }}
24+
</div>
2125
{% endblock %}

djangoproject/templates/foundation/meeting_detail.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{% extends "base_foundation.html" %}
22
{% load i18n %}
3+
{% load blog_extras %} {# Load your custom filter #}
34

45
{% block og_title %}{% blocktranslate %}Meeting minutes: {{ meeting }}{% endblocktranslate %}{% endblock %}
56
{% block og_description %}{% blocktranslate %}Meeting minutes for {{ meeting }}{% endblocktranslate %}{% endblock %}
@@ -80,7 +81,7 @@ <h2>{% translate "Ongoing business" %}</h2>
8081
{% for business in ongoing_business %}
8182
<h3>{{ business.title }}</h3>
8283

83-
{{ business.body_html|safe }}
84+
{{ business.body_html|add_lazy_loading|safe }}
8485
{% endfor %}
8586
{% endif %}
8687

@@ -90,7 +91,7 @@ <h2>{% translate "New business" %}</h2>
9091
{% for business in new_business %}
9192
<h3>{{ business.title }}</h3>
9293

93-
{{ business.body_html|safe }}
94+
{{ business.body_html|add_lazy_loading|safe }}
9495
{% endfor %}
9596
{% endif %}
9697

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from django.test import TestCase
2+
from foundation.models import Meeting, Business
3+
from django.template import Context, Template
4+
5+
class LazyLoadingMeetingTemplateTests(TestCase):
6+
def test_meeting_body_html_has_lazy_loading(self):
7+
meeting = Meeting.objects.create(title="Test Meeting")
8+
business = Business.objects.create(title="Test Business", body_html='<p><img src="img.jpg"></p>')
9+
meeting.ongoing_business.add(business)
10+
11+
template = Template("""
12+
{% load blog_extras %}
13+
{{ business.body_html|add_lazy_loading|safe }}
14+
""")
15+
rendered = template.render(Context({'business': business}))
16+
self.assertIn('loading="lazy"', rendered)
17+
self.assertIn('decoding="async"', rendered)

0 commit comments

Comments
 (0)