Skip to content

Commit ec02a72

Browse files
committed
Support dj-value attribute.
1 parent 28703c0 commit ec02a72

8 files changed

Lines changed: 308 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 0.27.0-dev
4+
5+
- Add `django-compressor` integration.
6+
- Add `dj-value` attribute for rendering Django variables as an element's inner content.
7+
38
## 0.26.0
49

510
- Add `get_template_loaders` for easier `TEMPLATES` configuration in `settings.py`.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@
159159
Else
160160
</div> <!-- </div>{% endif %} -->
161161
```
162+
163+
```html
164+
<!-- value-attribute.html -->
165+
<div dj-value="request.user"> <!-- <div>{{ request.user }}</div> -->
166+
</div>
167+
```
162168
<!-- readme-examples-end -->
163169

164170
## 📖 Documentation

docs/source/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 0.27.0-dev
4+
5+
- Add `dj-value` attribute for rendering Django variables as an element's inner content.
6+
37
## 0.26.0
48

59
- Add `get_template_loaders` for easier `TEMPLATES` configuration in `settings.py`.

docs/source/tag-attributes.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,44 @@ The `dj-angles` approach is shown first and then the equivalent Django Template
6363
else
6464
</div>
6565
{% endif %}
66-
```
66+
```
67+
68+
## `value`
69+
70+
```html
71+
<div dj-value="request.user"></div>
72+
```
73+
74+
```html
75+
<div>{{ request.user }}</div>
76+
```
77+
78+
`dj-value` replaces the element's inner content with the value wrapped in `{{ }}`. It can be combined with `dj-if` to conditionally render a value:
79+
80+
```html
81+
<div dj-if="is_authenticated" dj-value="request.user"></div>
82+
```
83+
84+
```html
85+
{% if is_authenticated %}<div>{{ request.user }}</div>{% endif %}
86+
```
87+
88+
Filters and expressions are passed through as-is:
89+
90+
```html
91+
<div dj-value="user.name|upper"></div>
92+
```
93+
94+
```html
95+
<div>{{ user.name|upper }}</div>
96+
```
97+
98+
Void and self-closing tags are turned into paired tags so the value has a place to render:
99+
100+
```html
101+
<img dj-value="avatar.url" />
102+
```
103+
104+
```html
105+
<img>{{ avatar.url }}</img>
106+
```

src/dj_angles/replacers/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from dj_angles.replacers.attributes import replace_attributes
3+
from dj_angles.replacers.attributes import replace_attributes, replace_values
44
from dj_angles.replacers.comments import mask_comments
55
from dj_angles.replacers.tags import replace_tags
66
from dj_angles.replacers.variables import replace_variables
@@ -31,10 +31,13 @@ def convert_template(html: str, *, origin=None) -> str:
3131
# 2. Replace variables, e.g. `{{ foo or bar }}`
3232
html = replace_variables(html)
3333

34-
# 3. Replace tags, e.g. `<dj-include />`
34+
# 3. Replace value attributes, e.g. `<div dj-value="request.user">`
35+
html = replace_values(html)
36+
37+
# 4. Replace tags, e.g. `<dj-include />`
3538
html = replace_tags(html, origin=origin)
3639

37-
# 4. Unmask comments
40+
# 5. Unmask comments
3841
for i, comment in enumerate(comments):
3942
html = html.replace(f"__DJ_ANGLES_COMMENT_{i}__", comment)
4043

src/dj_angles/replacers/attributes.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,101 @@ def _remove_attribute(tag: str, attr_match: re.Match, tag_start: int) -> str:
281281
new_tag = re.sub(r"\s{2,}", " ", new_tag)
282282

283283
return new_tag
284+
285+
286+
def replace_values(html: str) -> str:
287+
"""Convert `dj-value` attributes to Django variable output.
288+
289+
The attribute is removed from the opening tag, the element's inner content
290+
is replaced with `{{ value }}`, and void/self-closing tags are turned into
291+
paired tags so the value has a place to render.
292+
293+
Args:
294+
html: The HTML string to process.
295+
296+
Returns:
297+
HTML with `dj-value` attributes replaced by Django template variables.
298+
"""
299+
300+
prefix = get_setting("initial_attribute_regex", default=r"(dj-)")
301+
302+
attr_pattern = rf"\s({prefix}value)(?:=(?:\"(?P<v1>[^\"]*)\"|" + r"'(?P<v2>[^']*)'" + r"|(?P<v3>[^\s>]+)))?"
303+
304+
elements = []
305+
306+
for match in re.finditer(attr_pattern, html):
307+
condition = match.group("v1") or match.group("v2") or match.group("v3") or ""
308+
309+
if not condition:
310+
attr_name = match.group(1)
311+
raise AssertionError(f"{attr_name} attribute must have a value")
312+
313+
# Find the start of the containing tag
314+
tag_start = html.rfind("<", 0, match.start())
315+
316+
# Find the end of the opening tag
317+
tag_end = html.find(">", match.end()) + 1
318+
319+
# Find the element's full extent (including closing tag)
320+
full_end = _find_element_end(html, tag_start, tag_end)
321+
322+
original_tag = html[tag_start:tag_end]
323+
original_full = html[tag_start:full_end]
324+
325+
# dj-value is only valid on opening tags
326+
if original_tag.startswith("</"):
327+
attr_name = match.group(1)
328+
raise AssertionError(f"Invalid use of {attr_name} attribute on closing tag")
329+
330+
elements.append(
331+
{
332+
"match": match,
333+
"tag_start": tag_start,
334+
"tag_end": tag_end,
335+
"full_end": full_end,
336+
"original_tag": original_tag,
337+
"original_full": original_full,
338+
"condition": condition,
339+
}
340+
)
341+
342+
# Sort by position so outermost elements are processed first
343+
elements.sort(key=lambda e: e["tag_start"])
344+
345+
edits: list[AtomicEdit] = []
346+
347+
for elem in elements:
348+
# Skip elements that are nested inside another dj-value element
349+
if any(
350+
other["tag_start"] <= elem["tag_start"] and other["full_end"] >= elem["full_end"]
351+
for other in elements
352+
if other is not elem
353+
):
354+
continue
355+
356+
# Remove the dj-value attribute from the opening tag
357+
cleaned_tag = _remove_attribute(elem["original_tag"], elem["match"], elem["tag_start"])
358+
359+
# Turn self-closing tags into regular tags so they can have inner content
360+
cleaned_tag = re.sub(r"\s*/>$", ">", cleaned_tag)
361+
362+
# Get the tag name
363+
tag_match = re.match(r"<(\w+)", cleaned_tag)
364+
tag_name = tag_match.group(1) if tag_match else ""
365+
366+
# Find the existing closing tag, or generate one
367+
closing_tag_matches = list(re.finditer(rf"</{tag_name}\s*>", elem["original_full"], re.IGNORECASE))
368+
closing_tag = closing_tag_matches[-1].group(0) if closing_tag_matches else f"</{tag_name}>"
369+
370+
replacement = f"{cleaned_tag}{{{{ {elem['condition']} }}}}{closing_tag}"
371+
372+
edits.append(
373+
AtomicEdit(
374+
position=elem["tag_start"],
375+
content=replacement,
376+
is_insert=False,
377+
end_position=elem["full_end"],
378+
)
379+
)
380+
381+
return apply_edits(html, edits)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from collections import namedtuple
2+
3+
import pytest
4+
5+
from dj_angles.replacers.attributes import replace_values
6+
7+
# Structure to store parameterize data
8+
Params = namedtuple(
9+
"Params",
10+
("original", "replacement"),
11+
)
12+
13+
14+
@pytest.mark.parametrize(
15+
Params._fields,
16+
(
17+
Params(
18+
original="<div dj-value='request.user'></div>",
19+
replacement="<div>{{ request.user }}</div>",
20+
),
21+
Params(
22+
original='<div dj-value="request.user"></div>',
23+
replacement="<div>{{ request.user }}</div>",
24+
),
25+
Params(
26+
original="<div dj-value=request.user></div>",
27+
replacement="<div>{{ request.user }}</div>",
28+
),
29+
Params(
30+
original="<div dj-value='x'>fallback</div>",
31+
replacement="<div>{{ x }}</div>",
32+
),
33+
Params(
34+
original="<div dj-value='user.name|upper'></div>",
35+
replacement="<div>{{ user.name|upper }}</div>",
36+
),
37+
Params(
38+
original="<img dj-value='x' />",
39+
replacement="<img>{{ x }}</img>",
40+
),
41+
Params(
42+
original="<input dj-value='x'>",
43+
replacement="<input>{{ x }}</input>",
44+
),
45+
Params(
46+
original='<div class="foo" dj-value="x"></div>',
47+
replacement='<div class="foo">{{ x }}</div>',
48+
),
49+
Params(
50+
original="<div dj-value='x'><span>nested</span></div>",
51+
replacement="<div>{{ x }}</div>",
52+
),
53+
Params(
54+
original="<div dj-value='x'> <span>nested</span> </div>",
55+
replacement="<div>{{ x }}</div>",
56+
),
57+
),
58+
)
59+
def test_replace_values(original, replacement):
60+
actual = replace_values(original)
61+
assert actual == replacement
62+
63+
64+
def test_empty_value_raises():
65+
with pytest.raises(AssertionError, match="dj-value attribute must have a value"):
66+
replace_values("<div dj-value=''></div>")
67+
68+
69+
def test_missing_value_raises():
70+
with pytest.raises(AssertionError, match="dj-value attribute must have a value"):
71+
replace_values("<div dj-value></div>")
72+
73+
74+
def test_closing_tag_raises():
75+
with pytest.raises(AssertionError, match="Invalid use of dj-value attribute on closing tag"):
76+
replace_values("</div dj-value='x'>")
77+
78+
79+
def test_multiple_elements():
80+
original = "<div dj-value='x'></div><span dj-value='y'></span>"
81+
expected = "<div>{{ x }}</div><span>{{ y }}</span>"
82+
83+
actual = replace_values(original)
84+
85+
assert actual == expected
86+
87+
88+
def test_nested_elements():
89+
original = "<div dj-value='outer'><span dj-value='inner'></span></div>"
90+
expected = "<div>{{ outer }}</div>"
91+
92+
actual = replace_values(original)
93+
94+
assert actual == expected

tests/dj_angles/replacers/test_convert_template.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,3 +846,57 @@ def test_if_nested_same_tag_bug():
846846
actual = convert_template(template)
847847

848848
assert actual == expected
849+
850+
851+
def test_value_simple():
852+
expected = "<div>{{ request.user }}</div>"
853+
854+
template = '<div dj-value="request.user"></div>'
855+
actual = convert_template(template)
856+
857+
assert actual == expected
858+
859+
860+
def test_value_replaces_content():
861+
expected = "<div>{{ request.user }}</div>"
862+
863+
template = '<div dj-value="request.user">fallback</div>'
864+
actual = convert_template(template)
865+
866+
assert actual == expected
867+
868+
869+
def test_value_with_filter():
870+
expected = "<div>{{ request.user|upper }}</div>"
871+
872+
template = '<div dj-value="request.user|upper"></div>'
873+
actual = convert_template(template)
874+
875+
assert actual == expected
876+
877+
878+
def test_value_with_if():
879+
expected = "{% if is_authenticated %}<div>{{ request.user }}</div>{% endif %}"
880+
881+
template = '<div dj-if="is_authenticated" dj-value="request.user"></div>'
882+
actual = convert_template(template)
883+
884+
assert actual == expected
885+
886+
887+
def test_value_void_element():
888+
expected = "<input>{{ request.user }}</input>"
889+
890+
template = '<input dj-value="request.user">'
891+
actual = convert_template(template)
892+
893+
assert actual == expected
894+
895+
896+
def test_value_self_closing_element():
897+
expected = "<img>{{ request.user }}</img>"
898+
899+
template = '<img dj-value="request.user" />'
900+
actual = convert_template(template)
901+
902+
assert actual == expected

0 commit comments

Comments
 (0)