diff --git a/HISTORY.md b/HISTORY.md index 4e0dc5c..3fc93e3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,9 +2,17 @@ ## 4.0.1 (unreleased) - -- Nothing changed yet. - +- Support Chameleon attribute expressions. + Fix incorrect splitting of Chameleon attribute expressions which led to + broken code. + This HTML `` + leads now correctly to: + ```html + + ``` + [thet] ## 4.0.0 (2026-04-10) diff --git a/zpretty/prettifier.py b/zpretty/prettifier.py index 097d89d..1b821c1 100644 --- a/zpretty/prettifier.py +++ b/zpretty/prettifier.py @@ -30,6 +30,8 @@ class ZPrettifier: _rcdata_tags = ("title", "textarea") _cdatas = [] _doctype = None + _chameleon_marker_prefix = f"data-chameleon-expr-{str(uuid4())}-" + _chameleon_expressions = [] def __init__(self, filename="", text="", encoding="utf8"): """Create a prettifier instance taking the contents @@ -106,6 +108,43 @@ def fix_rcdata_markup(self, soup): for child in parsed_children: tag.append(child) + def _extract_chameleon_expressions(self, text): + """Replace ${...} Chameleon template expressions with safe markers. + + BeautifulSoup's parser mangles ${...} expressions in attribute positions + because they contain characters invalid in HTML attribute names (spaces, + quotes). Replace them with data-* attribute markers before parsing and + restore them afterwards. + """ + self._chameleon_expressions = [] + result = [] + i = 0 + n = len(text) + while i < n: + if text[i] == "$" and i + 1 < n and text[i + 1] == "{": + depth = 1 + j = i + 2 + while j < n and depth > 0: + if text[j] == "{": + depth += 1 + elif text[j] == "}": + depth -= 1 + j += 1 + if depth == 0: + expr = text[i:j] + idx = len(self._chameleon_expressions) + marker = f"{self._chameleon_marker_prefix}{idx}" + self._chameleon_expressions.append(expr) + result.append(marker) + i = j + else: + result.append(text[i]) + i += 1 + else: + result.append(text[i]) + i += 1 + return "".join(result) + def _prepare_text(self): """This tweaks the text passed to the prettifier to overcome some limitations of the BeautifulSoup parser @@ -121,6 +160,7 @@ def _prepare_text(self): pass text = re.sub(self._cdata_pattern, self._cdata_marker, text) text = re.sub(self._doctype_pattern, self._doctype_marker, text) + text = self._extract_chameleon_expressions(text) # Get all the entities in the text and replace them with a marker # The text might contain undefined entities that BeautifulSoup @@ -175,6 +215,14 @@ def pretty_print(self, el): # Restore entities for entity, marker in self._entity_mapping.items(): prettified = prettified.replace(marker, entity) + # Restore Chameleon template expressions. + # Sort longest-index-first so that e.g. "prefix-10" is replaced before + # "prefix-1" (which is a prefix of "prefix-10" and would corrupt it). + for idx, expr in sorted( + enumerate(self._chameleon_expressions), key=lambda x: -len(str(x[0])) + ): + marker = f"{self._chameleon_marker_prefix}{idx}" + prettified = prettified.replace(marker, expr) if self._end_with_newline and not prettified.endswith("\n"): prettified += "\n" return prettified diff --git a/zpretty/tests/test_zpretty.py b/zpretty/tests/test_zpretty.py index af66060..7825068 100644 --- a/zpretty/tests/test_zpretty.py +++ b/zpretty/tests/test_zpretty.py @@ -204,6 +204,50 @@ def test_ampersand_and_column_in_separate_attrs(self): '\n\n', ) + def test_chameleon_expression_in_attribute_position(self): + # ${...} as a standalone attribute (dynamic attribute syntax) + self.assertPrettified( + '', + '\n', + ) + + def test_chameleon_expression_with_other_attributes(self): + self.assertPrettified( + '', # noqa: E501 + '\n', # noqa: E501 + ) + + def test_chameleon_expression_in_attribute_value(self): + self.assertPrettified( + '
hello
', + '
hello
\n', + ) + + def test_chameleon_expression_in_text_content(self): + self.assertPrettified( + "

Hello ${name}!

", + "

Hello ${name}!

\n", + ) + + def test_chameleon_expression_nested_braces(self): + self.assertPrettified( + '', + '\n', + ) + + def test_chameleon_expression_many_expressions(self): + # 10+ expressions: "prefix-1" is a prefix of "prefix-10" so restoration + # must replace longer markers first to avoid corrupting shorter ones. + items = "".join( + f'
  • ${{label{i}}}
  • ' for i in range(12) + ) + input_html = f"" + prettifier = ZPrettifier(FakeConfig(), text=input_html) + result = prettifier() + for i in range(12): + self.assertIn(f'href="${{{i}}}"', result) + self.assertIn(f">${{label{i}}}<", result) + def test_sample_html(self): self.prettify("sample_html.html")