Skip to content

Commit 99946f5

Browse files
Prevent duplicates when collecting template assets in asset templatetag (#119)
1 parent f59a441 commit 99946f5

File tree

5 files changed

+105
-38
lines changed

5 files changed

+105
-38
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Fixed
22+
23+
- Fixed the potential for duplicate asset tags in the `{% bird:css %}` and `{% bird:js %}` templatetags by using a `set` instead of a `list` when collecting a template's component assets.
24+
25+
### Changed
26+
27+
- **Internal**: Refactored asset rendering logic by centralizing tag name parsing and HTML tag generation to the `django_bird.staticfiles` module.
28+
2129
## [0.10.2]
2230

2331
### Changed

src/django_bird/staticfiles.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ def content_type(self):
2626
def ext(self):
2727
return f".{self.value}"
2828

29+
@classmethod
30+
def from_tag_name(cls, tag_name: str):
31+
try:
32+
asset_type = tag_name.split(":")[1]
33+
match asset_type:
34+
case "css":
35+
return cls.CSS
36+
case "js":
37+
return cls.JS
38+
case _:
39+
raise ValueError(f"Unknown asset type: {asset_type}")
40+
except IndexError as e:
41+
raise ValueError(f"Invalid tag name: {tag_name}") from e
42+
2943

3044
@dataclass(frozen=True, slots=True)
3145
class Asset:
@@ -39,6 +53,13 @@ def __hash__(self) -> int:
3953
def exists(self) -> bool:
4054
return self.path.exists()
4155

56+
def render(self):
57+
match self.type:
58+
case AssetType.CSS:
59+
return f'<link rel="stylesheet" href="{self.url}">'
60+
case AssetType.JS:
61+
return f'<script src="{self.url}"></script>'
62+
4263
@property
4364
def url(self) -> str:
4465
component_name = self.path.stem
Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,40 @@
11
# pyright: reportAny=false
22
from __future__ import annotations
33

4+
from typing import final
5+
46
from django import template
57
from django.template.base import Parser
68
from django.template.base import Token
79
from django.template.context import Context
810

9-
from django_bird._typing import TagBits
1011
from django_bird._typing import override
1112
from django_bird.components import components
12-
from django_bird.staticfiles import Asset
1313
from django_bird.staticfiles import AssetType
1414

1515
CSS_TAG = "bird:css"
1616
JS_TAG = "bird:js"
1717

1818

19-
def do_asset(parser: Parser, token: Token) -> AssetNode:
19+
def do_asset(_parser: Parser, token: Token) -> AssetNode:
2020
bits = token.split_contents()
21-
asset_type = parse_asset_type(bits)
22-
return AssetNode(asset_type)
23-
24-
25-
def parse_asset_type(bits: TagBits) -> AssetType:
2621
if len(bits) < 1:
2722
msg = "bird:assets tag requires at least one argument"
2823
raise template.TemplateSyntaxError(msg)
29-
3024
tag_name = bits[0]
31-
32-
try:
33-
asset_type = tag_name.split(":")[1]
34-
match asset_type:
35-
case "css":
36-
return AssetType.CSS
37-
case "js":
38-
return AssetType.JS
39-
case _:
40-
raise ValueError(f"Unknown asset type: {asset_type}")
41-
except IndexError as e:
42-
raise ValueError(f"Invalid tag name: {tag_name}") from e
25+
asset_type = AssetType.from_tag_name(tag_name)
26+
return AssetNode(asset_type)
4327

4428

29+
@final
4530
class AssetNode(template.Node):
4631
def __init__(self, asset_type: AssetType):
4732
self.asset_type = asset_type
4833

4934
@override
5035
def render(self, context: Context) -> str:
51-
component_assets = components.get_assets(self.asset_type)
52-
return self._render_assets(component_assets)
53-
54-
def _render_assets(self, assets: list[Asset]) -> str:
36+
assets = components.get_assets(self.asset_type)
5537
if not assets:
5638
return ""
57-
58-
if self.asset_type == AssetType.CSS:
59-
tags = (
60-
f'<link rel="stylesheet" href="{asset.url}">'
61-
for asset in sorted(assets, key=lambda a: a.path)
62-
)
63-
else: # JS
64-
tags = (
65-
f'<script src="{asset.url}"></script>'
66-
for asset in sorted(assets, key=lambda a: a.path)
67-
)
68-
69-
return "\n".join(tags)
39+
rendered = {asset.render() for asset in sorted(assets, key=lambda a: a.path)}
40+
return "\n".join(rendered)

tests/templatetags/test_asset.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,61 @@ def test_component_render_order(self, create_template, templates_dir):
204204
in rendered[body_start:]
205205
)
206206

207+
def test_asset_duplication(self, create_template, templates_dir):
208+
alert = TestComponent(
209+
name="alert", content='<div class="alert">{{ slot }}</div>'
210+
).create(templates_dir)
211+
alert_css = TestAsset(
212+
component=alert, content=".alert { color: red; }", asset_type=AssetType.CSS
213+
).create()
214+
alert_js = TestAsset(
215+
component=alert, content="console.log('alert');", asset_type=AssetType.JS
216+
).create()
217+
218+
base_path = templates_dir / "base.html"
219+
base_path.write_text("""
220+
<html>
221+
<head>
222+
<title>Test</title>
223+
{% bird:css %}
224+
</head>
225+
<body>
226+
{% bird alert %}Base Alert{% endbird %}
227+
{% bird alert %}Base Alert{% endbird %}
228+
{% bird alert %}Base Alert{% endbird %}
229+
{% block content %}{% endblock %}
230+
{% bird:js %}
231+
</body>
232+
</html>
233+
""")
234+
235+
child_path = templates_dir / "child.html"
236+
child_path.write_text("""
237+
{% extends 'base.html' %}
238+
{% block content %}
239+
{% bird alert %}Base Alert{% endbird %}
240+
{% bird alert %}Base Alert{% endbird %}
241+
{% bird alert %}Base Alert{% endbird %}
242+
{% endblock %}
243+
""")
244+
245+
template = create_template(child_path)
246+
247+
rendered = template.render({})
248+
249+
assert (
250+
rendered.count(
251+
f'<link rel="stylesheet" href="/__bird__/assets/{alert.name}/{alert_css.file.name}">'
252+
)
253+
== 1
254+
)
255+
assert (
256+
rendered.count(
257+
f'<script src="/__bird__/assets/{alert.name}/{alert_js.file.name}"></script>'
258+
)
259+
== 1
260+
)
261+
207262

208263
class TestNode:
209264
def test_no_template(self):

tests/test_staticfiles.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ def test_exists_nonexistent(self):
3232
missing_asset = Asset(Path("missing.css"), AssetType.CSS)
3333
assert missing_asset.exists() is False
3434

35+
@pytest.mark.parametrize(
36+
"asset,expected_html_tag_bits",
37+
[
38+
(Asset(Path("static.css"), AssetType.CSS), 'link rel="stylesheet" href='),
39+
(Asset(Path("static.js"), AssetType.JS), "script src="),
40+
],
41+
)
42+
def test_render(self, asset, expected_html_tag_bits):
43+
rendered = asset.render()
44+
assert expected_html_tag_bits in rendered
45+
assert asset.url in rendered
46+
3547
@pytest.mark.parametrize(
3648
"path,expected",
3749
[

0 commit comments

Comments
 (0)