Skip to content

Commit 10aff54

Browse files
koxudaxidavepeck
andauthored
Enable string interpolation with multiple placeholders in HTML attribute (#59)
* Add placeholder replacement functionality for interpolated attribute values * Fix string formatting in attribute value tests for consistency * Fix logic for segment processing in attribute interpolation * Add a couple tests so I'm sure I understand the behavior here. * Update README --------- Co-authored-by: Dave <[email protected]>
1 parent 3b30c07 commit 10aff54

File tree

3 files changed

+154
-7
lines changed

3 files changed

+154
-7
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ button = html(t"<button id={element_id}>Click me</button>")
9797
# <button id="my-button">Click me</button>
9898
```
9999

100+
Multiple substitutions in a single attribute are supported too:
101+
102+
```python
103+
first = "Alice"
104+
last = "Smith"
105+
button = html(t'<button data-name="{first} {last}">Click me</button>')
106+
# <button data-name="Alice Smith">Click me</button>
107+
```
108+
100109
Boolean attributes are supported too. Just use a boolean value in the attribute
101110
position:
102111

tdom/processor.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import random
2+
import re
23
import string
34
import sys
45
import typing as t
@@ -53,6 +54,7 @@ def format_interpolation(interpolation: Interpolation) -> object:
5354

5455
_PLACEHOLDER_PREFIX = f"t🐍-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
5556
_PP_LEN = len(_PLACEHOLDER_PREFIX)
57+
_PLACEHOLDER_PATTERN = re.compile(re.escape(_PLACEHOLDER_PREFIX) + r"(\d+)")
5658

5759

5860
def _placeholder(i: int) -> str:
@@ -65,6 +67,47 @@ def _placholder_index(s: str) -> int:
6567
return int(s[_PP_LEN:])
6668

6769

70+
def _replace_placeholders_in_string(
71+
value: str, interpolations: tuple[Interpolation, ...]
72+
) -> object:
73+
"""Replace any placeholders embedded within a string attribute value."""
74+
segments: list[tuple[str, object]] = []
75+
has_static_content = False
76+
last_index = 0
77+
78+
for match in _PLACEHOLDER_PATTERN.finditer(value):
79+
if match.start() > last_index:
80+
static_segment = value[last_index : match.start()]
81+
segments.append(("static", static_segment))
82+
if static_segment:
83+
has_static_content = True
84+
85+
index = int(match.group(1))
86+
interpolation = interpolations[index]
87+
formatted = format_interpolation(interpolation)
88+
segments.append(("dynamic", formatted))
89+
last_index = match.end()
90+
91+
if last_index < len(value):
92+
static_segment = value[last_index:]
93+
segments.append(("static", static_segment))
94+
if static_segment:
95+
has_static_content = True
96+
97+
if not segments:
98+
return value
99+
100+
dynamic_segments = [segment for segment in segments if segment[0] == "dynamic"]
101+
102+
if not has_static_content and len(dynamic_segments) == 1 and len(segments) == 1:
103+
return dynamic_segments[0][1]
104+
105+
return "".join(
106+
str(segment[1]) if segment[0] == "static" else str(segment[1])
107+
for segment in segments
108+
)
109+
110+
68111
def _instrument(
69112
strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...]
70113
) -> t.Iterable[str]:
@@ -256,13 +299,22 @@ def _substitute_interpolated_attrs(
256299
"""
257300
new_attrs: dict[str, object | None] = {}
258301
for key, value in attrs.items():
259-
if value and value.startswith(_PLACEHOLDER_PREFIX):
260-
# Interpolated attribute value
261-
index = _placholder_index(value)
262-
interpolation = interpolations[index]
263-
interpolated_value = format_interpolation(interpolation)
264-
new_attrs[key] = interpolated_value
265-
elif key.startswith(_PLACEHOLDER_PREFIX):
302+
if isinstance(value, str):
303+
matches = tuple(_PLACEHOLDER_PATTERN.finditer(value))
304+
if matches:
305+
if len(matches) == 1:
306+
match = matches[0]
307+
if match.start() == 0 and match.end() == len(value):
308+
index = int(match.group(1))
309+
interpolation = interpolations[index]
310+
interpolated_value = format_interpolation(interpolation)
311+
new_attrs[key] = interpolated_value
312+
continue
313+
314+
new_attrs[key] = _replace_placeholders_in_string(value, interpolations)
315+
continue
316+
317+
if key.startswith(_PLACEHOLDER_PREFIX):
266318
# Spread attributes
267319
index = _placholder_index(key)
268320
interpolation = interpolations[index]

tdom/processor_test.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,19 @@ def test_interpolated_class_attribute():
460460
assert str(node) == '<button class="btn btn-primary active">Click me</button>'
461461

462462

463+
def test_interpolated_class_attribute_with_multiple_placeholders():
464+
classes1 = ["btn", "btn-primary"]
465+
classes2 = [False and "disabled", None, {"active": True}]
466+
node = html(t'<button class="{classes1} {classes2}">Click me</button>')
467+
# CONSIDER: Is this what we want? Currently, when we have multiple
468+
# placeholders in a single attribute, we treat it as a string attribute.
469+
assert node == Element(
470+
"button",
471+
attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
472+
children=[Text("Click me")],
473+
)
474+
475+
463476
def test_interpolated_attribute_spread_with_class_attribute():
464477
attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
465478
node = html(t"<button {attrs}>Click me</button>")
@@ -471,6 +484,52 @@ def test_interpolated_attribute_spread_with_class_attribute():
471484
assert str(node) == '<button id="button1" class="btn btn-primary">Click me</button>'
472485

473486

487+
def test_interpolated_attribute_value_embedded_placeholder():
488+
slug = "item42"
489+
node = html(t"<div data-id='prefix-{slug}'></div>")
490+
assert node == Element(
491+
"div",
492+
attrs={"data-id": "prefix-item42"},
493+
children=[],
494+
)
495+
assert str(node) == '<div data-id="prefix-item42"></div>'
496+
497+
498+
def test_interpolated_attribute_value_with_static_prefix_and_suffix():
499+
counter = 3
500+
node = html(t'<div data-id="item-{counter}-suffix"></div>')
501+
assert node == Element(
502+
"div",
503+
attrs={"data-id": "item-3-suffix"},
504+
children=[],
505+
)
506+
assert str(node) == '<div data-id="item-3-suffix"></div>'
507+
508+
509+
def test_interpolated_attribute_value_multiple_placeholders():
510+
start = 1
511+
end = 5
512+
node = html(t'<div data-range="{start}-{end}"></div>')
513+
assert node == Element(
514+
"div",
515+
attrs={"data-range": "1-5"},
516+
children=[],
517+
)
518+
assert str(node) == '<div data-range="1-5"></div>'
519+
520+
521+
def test_interpolated_attribute_value_multiple_placeholders_no_quotes():
522+
start = 1
523+
end = 5
524+
node = html(t"<div data-range={start}-{end}></div>")
525+
assert node == Element(
526+
"div",
527+
attrs={"data-range": "1-5"},
528+
children=[],
529+
)
530+
assert str(node) == '<div data-range="1-5"></div>'
531+
532+
474533
def test_interpolated_data_attributes():
475534
data = {"user-id": 123, "role": "admin", "wild": True}
476535
node = html(t"<div data={data}>User Info</div>")
@@ -485,6 +544,13 @@ def test_interpolated_data_attributes():
485544
)
486545

487546

547+
def test_interpolated_data_attribute_multiple_placeholders():
548+
confusing = {"user-id": "user-123"}
549+
placeholders = {"role": "admin"}
550+
with pytest.raises(TypeError):
551+
_ = html(t'<div data="{confusing} {placeholders}">User Info</div>')
552+
553+
488554
def test_interpolated_aria_attributes():
489555
aria = {"label": "Close", "hidden": True, "another": False, "more": None}
490556
node = html(t"<button aria={aria}>X</button>")
@@ -499,6 +565,13 @@ def test_interpolated_aria_attributes():
499565
)
500566

501567

568+
def test_interpolated_aria_attribute_multiple_placeholders():
569+
confusing = {"label": "Close"}
570+
placeholders = {"hidden": True}
571+
with pytest.raises(TypeError):
572+
_ = html(t'<button aria="{confusing} {placeholders}">X</button>')
573+
574+
502575
def test_interpolated_style_attribute():
503576
styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
504577
node = html(t"<p style={styles}>Warning!</p>")
@@ -513,6 +586,19 @@ def test_interpolated_style_attribute():
513586
)
514587

515588

589+
def test_interpolated_style_attribute_multiple_placeholders():
590+
styles1 = {"color": "red"}
591+
styles2 = {"font-weight": "bold"}
592+
node = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
593+
# CONSIDER: Is this what we want? Currently, when we have multiple
594+
# placeholders in a single attribute, we treat it as a string attribute.
595+
assert node == Element(
596+
"p",
597+
attrs={"style": "{'color': 'red'} {'font-weight': 'bold'}"},
598+
children=[Text("Warning!")],
599+
)
600+
601+
516602
def test_style_attribute_str():
517603
styles = "color: red; font-weight: bold;"
518604
node = html(t"<p style={styles}>Warning!</p>")

0 commit comments

Comments
 (0)