Skip to content

Commit b1f39b0

Browse files
committed
More robust support for comments.
1 parent c2ccae3 commit b1f39b0

4 files changed

Lines changed: 226 additions & 19 deletions

File tree

src/dj_angles/replacers/__init__.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
2-
import re
32

43
from dj_angles.replacers.attributes import replace_attributes
4+
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
7+
from dj_angles.settings import get_setting
78

89
logger = logging.getLogger(__name__)
910

@@ -20,20 +21,9 @@ def convert_template(html: str, *, origin=None) -> str:
2021
"""
2122

2223
# 0. Mask comments
23-
comments = []
24+
initial_tag_regex = get_setting("initial_tag_regex", default=r"(dj-)")
2425

25-
def mask_match(match):
26-
comments.append(match.group(0))
27-
return f"__DJ_ANGLES_COMMENT_{len(comments) - 1}__"
28-
29-
# Django single line comments {# ... #}
30-
html = re.sub(r"\{#.*?#\}", mask_match, html, flags=re.DOTALL)
31-
32-
# Django block comments {% comment %}...{% endcomment %}
33-
html = re.sub(r"\{%\s*comment\s*%\}.*?\{%\s*endcomment\s*%\}", mask_match, html, flags=re.DOTALL | re.IGNORECASE)
34-
35-
# Custom dj-comment Tags <dj-comment>...</dj-comment>
36-
html = re.sub(r"<dj-comment>.*?</dj-comment>", mask_match, html, flags=re.DOTALL | re.IGNORECASE)
26+
(html, comments) = mask_comments(html, initial_tag_regex=initial_tag_regex)
3727

3828
# 1. Replace attributes, e.g. `<div dj-if="condition">`
3929
html = replace_attributes(html)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import re
2+
3+
4+
def mask_comments(html: str, initial_tag_regex: str = r"(dj-)") -> tuple[str, list[str]]:
5+
"""Mask Django and custom comments in the HTML string.
6+
7+
Args:
8+
html: The HTML string to process.
9+
initial_tag_regex: The regex for the tag prefix.
10+
11+
Returns:
12+
A tuple containing the masked HTML string and a list of original comments.
13+
"""
14+
comments = []
15+
16+
# Combined pattern for all comment types
17+
# 1. Django comment block start: {% comment %}
18+
# 2. Django comment block end: {% endcomment %}
19+
# 3. Custom comment start: <prefix-comment>
20+
# 4. Custom comment end: </prefix-comment>
21+
# 5. Django single line comment: {# ... #}
22+
comment_pattern = re.compile(
23+
r"(?P<django_block_start>\{%\s*comment\s*%\})"
24+
r"|(?P<django_block_end>\{%\s*endcomment\s*%\})"
25+
r"|(?P<dj_comment_start><" + initial_tag_regex + r"comment(?:>|\s[^>]*>))"
26+
r"|(?P<dj_comment_end></" + initial_tag_regex + r"comment>)"
27+
r"|(?P<django_single>\{#.*?#\})",
28+
flags=re.IGNORECASE | re.DOTALL,
29+
)
30+
31+
masked_html_parts = []
32+
last_pos = 0
33+
active_comment_start = None
34+
nesting_depth = 0
35+
in_django_block = False
36+
37+
for match in comment_pattern.finditer(html):
38+
# If we are inside a Django block comment, we ONLY look for the end of THAT block
39+
if in_django_block:
40+
if match.group("django_block_end"):
41+
in_django_block = False
42+
43+
if active_comment_start is not None:
44+
full_comment = html[active_comment_start : match.end()]
45+
comments.append(full_comment)
46+
masked_html_parts.append(f"__DJ_ANGLES_COMMENT_{len(comments) - 1}__")
47+
active_comment_start = None
48+
last_pos = match.end()
49+
continue
50+
51+
# If we are inside a dj-comment block, manage nesting depth
52+
if nesting_depth > 0:
53+
if match.group("dj_comment_start"):
54+
nesting_depth += 1
55+
elif match.group("dj_comment_end"):
56+
nesting_depth -= 1
57+
58+
if nesting_depth == 0:
59+
if active_comment_start is not None:
60+
full_comment = html[active_comment_start : match.end()]
61+
comments.append(full_comment)
62+
masked_html_parts.append(f"__DJ_ANGLES_COMMENT_{len(comments) - 1}__")
63+
active_comment_start = None
64+
last_pos = match.end()
65+
continue
66+
67+
# Not currently in any comment, looking for start
68+
if match.group("django_single"):
69+
# Single line comment - immediate replacement
70+
masked_html_parts.append(html[last_pos : match.start()])
71+
full_comment = match.group(0)
72+
comments.append(full_comment)
73+
masked_html_parts.append(f"__DJ_ANGLES_COMMENT_{len(comments) - 1}__")
74+
last_pos = match.end()
75+
76+
elif match.group("django_block_start"):
77+
# Start of Django block
78+
masked_html_parts.append(html[last_pos : match.start()])
79+
active_comment_start = match.start()
80+
last_pos = match.start() # Set last_pos to start of unclosed block
81+
in_django_block = True
82+
83+
elif match.group("dj_comment_start"):
84+
# Start of dj-comment
85+
masked_html_parts.append(html[last_pos : match.start()])
86+
active_comment_start = match.start()
87+
last_pos = match.start() # Set last_pos to start of unclosed block
88+
nesting_depth = 1
89+
90+
elif match.group("django_block_end") or match.group("dj_comment_end"):
91+
# Orphaned end tag - mask it to prevent crashes in subsequent replacers
92+
masked_html_parts.append(html[last_pos : match.start()])
93+
full_comment = match.group(0)
94+
comments.append(full_comment)
95+
masked_html_parts.append(f"__DJ_ANGLES_COMMENT_{len(comments) - 1}__")
96+
last_pos = match.end()
97+
98+
# Add remaining text (either everything since last match, or the unclosed block)
99+
masked_html_parts.append(html[last_pos:])
100+
101+
return "".join(masked_html_parts), comments

tests/dj_angles/replacers/test_comments.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,13 @@ def test_commented_out_tag_is_ignored():
1515

1616
actual = convert_template(template)
1717

18-
# Let's see what we get. I suspect it currently converts it.
19-
print(f"\nACTUAL:\n{actual}")
20-
2118
# If we want it to be ignored, this assertion should pass:
2219
assert "<dj-include 'fake-partial.html' />" in actual
2320

2421

2522
def test_single_line_comment_ignored():
2623
template = "{# <dj-include 'fake-partial.html' /> #}"
2724
actual = convert_template(template)
28-
print(f"\nACTUAL SINGLE LINE:\n{actual}")
2925
assert "<dj-include 'fake-partial.html' />" in actual
3026

3127

@@ -36,5 +32,30 @@ def test_custom_dj_comment_ignored():
3632
</dj-comment>
3733
"""
3834
actual = convert_template(template)
39-
print(f"\nACTUAL DJ-COMMENT:\n{actual}")
35+
4036
assert "<dj-include 'fake-partial.html' />" in actual
37+
38+
39+
def test_nested_dj_comments():
40+
template = """
41+
<dj-comment>
42+
Outer start
43+
<dj-comment>
44+
Inner
45+
</dj-comment>
46+
Outer end
47+
<dj-include 'should-be-ignored' />
48+
</dj-comment>
49+
"""
50+
actual = convert_template(template)
51+
52+
# Everything inside the top-level <dj-comment> should be ignored.
53+
# So we should see the original <dj-include ...> tag, NOT a processed result.
54+
assert "<dj-include 'should-be-ignored' />" in actual
55+
56+
57+
def test_orphaned_closing_tag_does_not_crash():
58+
template = "Some content </dj-comment> and more content"
59+
# This should not raise IndexError: pop from an empty deque
60+
actual = convert_template(template)
61+
assert "</dj-comment>" in actual
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from dj_angles.replacers.comments import mask_comments
2+
3+
4+
def test_mask_django_single_line():
5+
html = "Hello {# comment #} World"
6+
masked_html, comments = mask_comments(html)
7+
assert masked_html == "Hello __DJ_ANGLES_COMMENT_0__ World"
8+
assert comments == ["{# comment #}"]
9+
10+
11+
def test_mask_django_block():
12+
html = "Hello {% comment %} block {% endcomment %} World"
13+
masked_html, comments = mask_comments(html)
14+
assert masked_html == "Hello __DJ_ANGLES_COMMENT_0__ World"
15+
assert comments == ["{% comment %} block {% endcomment %}"]
16+
17+
18+
def test_mask_dj_comment():
19+
html = "Hello <dj-comment> custom </dj-comment> World"
20+
masked_html, comments = mask_comments(html)
21+
assert masked_html == "Hello __DJ_ANGLES_COMMENT_0__ World"
22+
assert comments == ["<dj-comment> custom </dj-comment>"]
23+
24+
25+
def test_mask_nested_dj_comment():
26+
html = """
27+
<dj-comment>
28+
outer
29+
<dj-comment>
30+
inner
31+
</dj-comment>
32+
end outer
33+
</dj-comment>
34+
"""
35+
masked_html, comments = mask_comments(html)
36+
assert "__DJ_ANGLES_COMMENT_0__" in masked_html
37+
assert len(comments) == 1
38+
assert "outer" in comments[0]
39+
assert "inner" in comments[0]
40+
41+
42+
def test_mask_multiple_comments():
43+
html = "{# one #} middle <dj-comment> two </dj-comment> end"
44+
masked_html, comments = mask_comments(html)
45+
assert masked_html == "__DJ_ANGLES_COMMENT_0__ middle __DJ_ANGLES_COMMENT_1__ end"
46+
assert len(comments) == 2
47+
assert comments[0] == "{# one #}"
48+
assert comments[1] == "<dj-comment> two </dj-comment>"
49+
50+
51+
def test_unclosed_comment():
52+
html = "Hello <dj-comment> unclosed"
53+
masked_html, comments = mask_comments(html)
54+
# Unclosed comments should be treated as text (not masked)
55+
assert masked_html == "Hello <dj-comment> unclosed"
56+
assert len(comments) == 0
57+
58+
59+
def test_django_block_inside_dj_comment():
60+
html = "<dj-comment>{% comment %} inner {% endcomment %}</dj-comment>"
61+
masked_html, comments = mask_comments(html)
62+
assert masked_html == "__DJ_ANGLES_COMMENT_0__"
63+
assert len(comments) == 1
64+
assert "{% comment %}" in comments[0]
65+
66+
67+
def test_mask_custom_prefix():
68+
html = "Hello <my-comment> custom </my-comment> World"
69+
# Use raw string for prefix to avoid escape issues in test
70+
masked_html, comments = mask_comments(html, initial_tag_regex=r"(my-)")
71+
assert masked_html == "Hello __DJ_ANGLES_COMMENT_0__ World"
72+
assert comments == ["<my-comment> custom </my-comment>"]
73+
74+
75+
def test_mask_unclosed_tag():
76+
html = "Normal <dj-comment> This is unclosed"
77+
masked_html, comments = mask_comments(html)
78+
# Unclosed tags are kept as-is, not masked
79+
assert masked_html == html
80+
assert len(comments) == 0
81+
82+
83+
def test_mask_orphaned_closing_tag():
84+
html = "Orphaned </dj-comment> tag"
85+
masked_html, comments = mask_comments(html)
86+
# Orphaned end tags should be masked to prevent downstream crashes
87+
assert masked_html == "Orphaned __DJ_ANGLES_COMMENT_0__ tag"
88+
assert comments == ["</dj-comment>"]
89+
90+
91+
def test_mask_mixed_orphaned_tags():
92+
html = "</dj-comment> {% endcomment %}"
93+
masked_html, comments = mask_comments(html)
94+
assert masked_html == "__DJ_ANGLES_COMMENT_0__ __DJ_ANGLES_COMMENT_1__"
95+
assert comments == ["</dj-comment>", "{% endcomment %}"]

0 commit comments

Comments
 (0)