1616
1717
1818@dataclass
19- class ConditionalElement :
20- """Represents an element with a dj-if/elif/else attribute."""
19+ class Element :
20+ """Represents an HTML element with a dj-* attribute."""
21+
22+ tag_name : str
23+ """Name of the HTML tag, e.g. ``div`` or ``img``."""
24+
25+ tag_start : int
26+ """Position of the ``<`` that starts the opening tag in the original HTML."""
27+
28+ tag_end : int
29+ """Position just after the ``>`` that ends the opening tag."""
30+
31+ full_end : int
32+ """Position just after the element's closing tag, or ``tag_end`` for void/self-closing tags."""
33+
34+ original_tag : str
35+ """The original opening tag string, including the dj-* attribute."""
36+
37+ original_full : str
38+ """The full original element, from the opening tag through the closing tag."""
39+
40+ attr_match : re .Match
41+ """Regex match for the dj-* attribute that triggered this element's discovery."""
42+
43+ type : str
44+ """The dj-* attribute type, e.g. ``if``, ``elif``, ``else``, or ``value``."""
45+
46+ value : str = ""
47+ """The value of the dj-* attribute, e.g. the condition for ``dj-if`` or the variable for ``dj-value``."""
2148
22- type : str # 'if', 'elif', 'else'
23- condition : str # The condition value (empty for else)
24- start_pos : int # Position of '<' in original HTML
25- end_pos : int # Position after '>' of opening tag
26- full_end_pos : int # Position after closing tag (full element)
27- original_tag : str # The original opening tag
28- original_full : str # The full original element with content
29- attr_match : re .Match # The regex match for the attribute
49+ @property
50+ def is_closing (self ) -> bool :
51+ """Whether the element is a closing tag."""
52+ return self .original_tag .startswith ("</" )
53+
54+ def remove_attribute (self ) -> str :
55+ """Remove the dj-* attribute from the opening tag."""
56+ return _remove_attribute (self .original_tag , self .attr_match , self .tag_start )
57+
58+ def contains (self , other : "Element" ) -> bool :
59+ """Whether this element fully contains another element."""
60+ return self .tag_start <= other .tag_start and other .full_end <= self .full_end
61+
62+ def closing_tag (self ) -> str :
63+ """Return the existing closing tag or generate one."""
64+ matches = list (re .finditer (rf"</{ self .tag_name } \s*>" , self .original_full , re .IGNORECASE ))
65+ return matches [- 1 ].group (0 ) if matches else f"</{ self .tag_name } >"
66+
67+
68+ @dataclass
69+ class ConditionalElement (Element ):
70+ """Represents an element with a dj-if/elif/else attribute."""
3071
3172 # Chain linking (assigned during processing)
3273 chain_id : int = - 1
3374 next_in_chain : Optional ["ConditionalElement" ] = None
3475
76+ @property
77+ def condition (self ) -> str :
78+ """The condition value of the attribute (empty for else)."""
79+ return self .value
80+
3581
3682def replace_attributes (html : str ) -> str :
3783 """Convert dj-if/elif/else attributes to Django template tags.
@@ -91,30 +137,24 @@ def _find_conditional_elements(html: str, prefix: str) -> list[ConditionalElemen
91137 # Value is in named groups v1 (double), v2 (single), or v3 (unquoted)
92138 condition = match .group ("v1" ) or match .group ("v2" ) or match .group ("v3" ) or ""
93139
94- # Find the start of the containing tag
95- tag_start = html .rfind ("<" , 0 , match .start ())
96-
97- # Find the end of the opening tag
98- tag_end = html .find (">" , match .end ()) + 1
99-
100- # Find the element's full extent (including closing tag)
101- full_end = _find_element_end (html , tag_start , tag_end )
140+ element = _find_element (html , match , attr_type )
102141
103142 elements .append (
104143 ConditionalElement (
105- type = attr_type ,
106- condition = condition ,
107- start_pos = tag_start ,
108- end_pos = tag_end ,
109- full_end_pos = full_end ,
110- original_tag = html [tag_start :tag_end ],
111- original_full = html [tag_start :full_end ],
144+ tag_name = element .tag_name ,
145+ tag_start = element .tag_start ,
146+ tag_end = element .tag_end ,
147+ full_end = element .full_end ,
148+ original_tag = element .original_tag ,
149+ original_full = element .original_full ,
112150 attr_match = match ,
151+ type = attr_type ,
152+ value = condition ,
113153 )
114154 )
115155
116156 # Sort by position
117- elements .sort (key = lambda e : e .start_pos )
157+ elements .sort (key = lambda e : e .tag_start )
118158
119159 return elements
120160
@@ -159,6 +199,36 @@ def _find_element_end(html: str, tag_start: int, tag_end: int) -> int:
159199 return pos
160200
161201
202+ def _find_element (html : str , match : re .Match , attr_type : str ) -> Element :
203+ """Find the HTML element containing a dj-* attribute match."""
204+
205+ # Find the start of the containing tag
206+ tag_start = html .rfind ("<" , 0 , match .start ())
207+
208+ # Find the end of the opening tag
209+ tag_end = html .find (">" , match .end ()) + 1
210+
211+ # Find the element's full extent (including closing tag)
212+ full_end = _find_element_end (html , tag_start , tag_end )
213+
214+ original_tag = html [tag_start :tag_end ]
215+ original_full = html [tag_start :full_end ]
216+
217+ tag_match = re .match (r"</?(\w+)" , original_tag )
218+ tag_name = tag_match .group (1 ) if tag_match else ""
219+
220+ return Element (
221+ tag_name = tag_name ,
222+ tag_start = tag_start ,
223+ tag_end = tag_end ,
224+ full_end = full_end ,
225+ original_tag = original_tag ,
226+ original_full = original_full ,
227+ attr_match = match ,
228+ type = attr_type ,
229+ )
230+
231+
162232def _link_chains (elements : list [ConditionalElement ]) -> None :
163233 """Link if-elif-else elements into chains based on sibling relationships.
164234
@@ -202,7 +272,7 @@ def _find_preceding_sibling(
202272
203273 # Key check: candidate's full element must END before this starts
204274 # This means they're siblings, not parent-child
205- if candidate .full_end_pos <= elem .start_pos :
275+ if candidate .full_end <= elem .tag_start :
206276 # Additional check: candidate must not be inside another element
207277 # that our else is OUTSIDE of (meaning different hierarchy levels)
208278 is_nested_inside_other = False
@@ -212,7 +282,7 @@ def _find_preceding_sibling(
212282 continue
213283
214284 # If candidate is inside 'other', and elem is outside 'other'
215- if other .start_pos < candidate .start_pos < other .full_end_pos and elem .start_pos >= other .full_end_pos :
285+ if other .tag_start < candidate .tag_start < other .full_end and elem .tag_start >= other .full_end :
216286 is_nested_inside_other = True
217287
218288 break
@@ -251,18 +321,18 @@ def _apply_atomic_edits(elements: list[ConditionalElement], html: str) -> str:
251321 continue
252322
253323 if start_tag :
254- edits .append (AtomicEdit (position = elem .start_pos , content = start_tag ))
324+ edits .append (AtomicEdit (position = elem .tag_start , content = start_tag ))
255325
256326 # 2. Remove the attribute (replace with cleaned tag)
257- new_tag = _remove_attribute (elem .original_tag , elem .attr_match , elem .start_pos )
258- edits .append (AtomicEdit (position = elem .start_pos , content = new_tag , is_insert = False , end_position = elem .end_pos ))
327+ new_tag = _remove_attribute (elem .original_tag , elem .attr_match , elem .tag_start )
328+ edits .append (AtomicEdit (position = elem .tag_start , content = new_tag , is_insert = False , end_position = elem .tag_end ))
259329
260330 # 3. Insert endif if needed
261331 # Logic: If no next element in chain, AND it's a structural tag (if/elif/else), add endif.
262332 should_add_endif = elem .next_in_chain is None and elem .type in ("if" , "elif" , "else" )
263333
264334 if should_add_endif :
265- edits .append (AtomicEdit (position = elem .full_end_pos , content = "{% endif %}" ))
335+ edits .append (AtomicEdit (position = elem .full_end , content = "{% endif %}" ))
266336
267337 return apply_edits (html , edits )
268338
@@ -301,7 +371,7 @@ def replace_values(html: str) -> str:
301371
302372 attr_pattern = rf"\s({ prefix } value)(?:=(?:\"(?P<v1>[^\"]*)\"|" + r"'(?P<v2>[^']*)'" + r"|(?P<v3>[^\s>]+)))?"
303373
304- elements = []
374+ elements : list [ Element ] = []
305375
306376 for match in re .finditer (attr_pattern , html ):
307377 condition = match .group ("v1" ) or match .group ("v2" ) or match .group ("v3" ) or ""
@@ -310,71 +380,40 @@ def replace_values(html: str) -> str:
310380 attr_name = match .group (1 )
311381 raise AssertionError (f"{ attr_name } attribute must have a value" )
312382
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 ]
383+ element = _find_element (html , match , "value" )
324384
325385 # dj-value is only valid on opening tags
326- if original_tag . startswith ( "</" ) :
386+ if element . is_closing :
327387 attr_name = match .group (1 )
328388 raise AssertionError (f"Invalid use of { attr_name } attribute on closing tag" )
329389
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- )
390+ element .value = condition
391+ elements .append (element )
341392
342393 # Sort by position so outermost elements are processed first
343- elements .sort (key = lambda e : e [ " tag_start" ] )
394+ elements .sort (key = lambda e : e . tag_start )
344395
345396 edits : list [AtomicEdit ] = []
346397
347398 for elem in elements :
348399 # 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- ):
400+ if any (other .contains (elem ) for other in elements if other is not elem ):
354401 continue
355402
356403 # Remove the dj-value attribute from the opening tag
357- cleaned_tag = _remove_attribute ( elem [ "original_tag" ], elem [ "match" ], elem [ "tag_start" ] )
404+ cleaned_tag = elem . remove_attribute ( )
358405
359406 # Turn self-closing tags into regular tags so they can have inner content
360407 cleaned_tag = re .sub (r"\s*/>$" , ">" , cleaned_tag )
361408
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 } "
409+ replacement = f"{ cleaned_tag } {{{{ { elem .value } }}}}{ elem .closing_tag ()} "
371410
372411 edits .append (
373412 AtomicEdit (
374- position = elem [ " tag_start" ] ,
413+ position = elem . tag_start ,
375414 content = replacement ,
376415 is_insert = False ,
377- end_position = elem [ " full_end" ] ,
416+ end_position = elem . full_end ,
378417 )
379418 )
380419
0 commit comments