@@ -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 )
0 commit comments