diff --git a/README.md b/README.md index 86346eb..2265d27 100644 --- a/README.md +++ b/README.md @@ -130,23 +130,29 @@ button = html(t'') # ``` -For flexibility, you can also provide a list of strings, dictionaries, or a mix -of both: +The `class` attribute can also be a dictionary to toggle classes on or off: ```python -classes = ["btn", "btn-primary", {"active": True}, None, False and "disabled"] -button = html(t'') +classes = {"active": True, "btn-primary": True} +button = html(t'') # ``` -See the -[`classnames()`](https://github.com/t-strings/tdom/blob/main/tdom/classnames_test.py) -helper function for more information on how class names are combined. +The `class` attribute can be specified more than once. The values are merged +from left to right. A common use case would be to update and/or extend default +classes: + +```python +classes = {"btn-primary": True, "btn-secondary": False} +button = html(t'') +assert str(button) == '' +``` #### The `style` Attribute -In addition to strings, you can also provide a dictionary of CSS properties and -values for the `style` attribute: +The `style` attribute has special handling to make it easy to combine multiple +styles from different sources. The simplest way is to provide a dictionary of +CSS properties and values for the `style` attribute: ```python # Style attributes from dictionaries @@ -155,6 +161,14 @@ styled = html(t"
Important text
") #Important text
``` +Style attributes can also be merged to extend a base style: + +```python +add_styles = {"font-weight": "bold"} +para = html(t'Important text
') +assert str(para) == 'Important text
' +``` + #### The `data` and `aria` Attributes The `data` and `aria` attributes also have special handling to convert @@ -195,7 +209,7 @@ Special attributes likes `class` behave as expected when combined with spreading: ```python -classes = ["btn", {"active": True}] +classes = {"btn": True, "active": True} attrs = {"class": classes, "id": "act_now", "data": {"wow": "such-attr"}} button = html(t'') # @@ -539,42 +553,6 @@ anywhere that expects an object with HTML representation. Converting a node to a string (via `str()` or `print()`) automatically renders it as HTML with proper escaping. -#### The `classnames()` Helper - -The `classnames()` function provides a flexible way to build class name strings -from various input types. It's particularly useful when you need to -conditionally include classes: - -```python -from tdom import classnames - -# Combine strings -assert classnames("btn", "btn-primary") == "btn btn-primary" - -# Use dictionaries for conditional classes -is_active = True -is_disabled = False -assert classnames("btn", { - "btn-active": is_active, - "btn-disabled": is_disabled -}) == "btn btn-active" - -# Mix lists, dicts, and strings -assert classnames( - "btn", - ["btn-large", "rounded"], - {"btn-primary": True, "btn-secondary": False}, - None, # Ignored - False # Ignored -) == "btn btn-large rounded btn-primary" - -# Nested lists are flattened -assert classnames(["btn", ["btn-primary", ["active"]]]) == "btn btn-primary active" -``` - -This function is automatically used when processing `class` attributes in -templates, so you can pass any of these input types directly in your t-strings. - #### Utilities The `tdom` package includes several utility functions for working with diff --git a/tdom/__init__.py b/tdom/__init__.py index d5fa015..4503582 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,13 +1,11 @@ from markupsafe import Markup, escape -from .classnames import classnames from .nodes import Comment, DocumentType, Element, Fragment, Node, Text from .processor import html # We consider `Markup` and `escape` to be part of this module's public API __all__ = [ - "classnames", "Comment", "DocumentType", "Element", diff --git a/tdom/classnames.py b/tdom/classnames.py deleted file mode 100644 index c82128c..0000000 --- a/tdom/classnames.py +++ /dev/null @@ -1,50 +0,0 @@ -def classnames(*args: object) -> str: - """ - Construct a space-separated class string from various inputs. - - Accepts strings, lists/tuples of strings, and dicts mapping class names to - boolean values. Ignores None and False values. - - Examples: - classnames("btn", "btn-primary") -> "btn btn-primary" - classnames("btn", {"btn-primary": True, "disabled": False}) -> "btn btn-primary" - classnames(["btn", "btn-primary"], {"disabled": True}) -> "btn btn-primary disabled" - classnames("btn", None, False, "active") -> "btn active" - - Args: - *args: Variable length argument list containing strings, lists/tuples, - or dicts. - - Returns: - A single string with class names separated by spaces. - """ - classes: list[str] = [] - # Use a queue to process arguments iteratively, preserving order. - queue = list(args) - - while queue: - arg = queue.pop(0) - - if not arg: # Handles None, False, empty strings/lists/dicts - continue - - if isinstance(arg, str): - classes.append(arg) - elif isinstance(arg, dict): - for key, value in arg.items(): - if value: - if not isinstance(key, str): - raise ValueError( - f"Classnames dictionary keys must be strings, found {key!r} of type {type(key).__name__}" - ) - classes.append(key) - elif isinstance(arg, (list, tuple)): - # Add items to the front of the queue to process them next, in order. - queue[0:0] = arg - elif isinstance(arg, bool): - pass # Explicitly ignore booleans not in a dict - else: - raise ValueError(f"Invalid class argument type: {type(arg).__name__}") - - # Filter out empty strings and join the result. - return " ".join(stripped for c in classes if (stripped := c.strip())) diff --git a/tdom/classnames_test.py b/tdom/classnames_test.py deleted file mode 100644 index 62e4d70..0000000 --- a/tdom/classnames_test.py +++ /dev/null @@ -1,78 +0,0 @@ -import pytest - -from .classnames import classnames - - -def test_classnames_empty(): - assert classnames() == "" - - -def test_classnames_strings(): - assert classnames("btn", "btn-primary") == "btn btn-primary" - - -def test_classnames_strings_strip(): - assert classnames(" btn ", " btn-primary ") == "btn btn-primary" - - -def test_cslx_empty_strings(): - assert classnames("", "btn", "", "btn-primary", "") == "btn btn-primary" - - -def test_clsx_booleans(): - assert classnames(True, False) == "" - - -def test_classnames_lists_and_tuples(): - assert ( - classnames(["btn", "btn-primary"], ("active", "disabled")) - == "btn btn-primary active disabled" - ) - - -def test_classnames_dicts(): - assert ( - classnames( - "btn", - {"btn-primary": True, "disabled": False, "active": True, "shown": "yes"}, - ) - == "btn btn-primary active shown" - ) - - -def test_classnames_mixed_inputs(): - assert ( - classnames( - "btn", - ["btn-primary", "active"], - {"disabled": True, "hidden": False}, - ("extra",), - ) - == "btn btn-primary active disabled extra" - ) - - -def test_classnames_ignores_none_and_false(): - assert ( - classnames("btn", None, False, "active", {"hidden": None, "visible": True}) - == "btn active visible" - ) - - -def test_classnames_raises_type_error_on_invalid_input(): - with pytest.raises(ValueError): - classnames(123) - - with pytest.raises(ValueError): - classnames(["btn", 456]) - - -def test_classnames_kitchen_sink(): - assert ( - classnames( - "foo", - [1 and "bar", {"baz": False, "bat": None}, ["hello", ["world"]]], - "cya", - ) - == "foo bar hello world cya" - ) diff --git a/tdom/placeholders.py b/tdom/placeholders.py index d5094ca..08f039a 100644 --- a/tdom/placeholders.py +++ b/tdom/placeholders.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import random import re import string @@ -5,59 +6,67 @@ from .template_utils import TemplateRef -_PLACEHOLDER_PREFIX = f"tš{''.join(random.choices(string.ascii_lowercase, k=2))}-" -_PLACEHOLDER_SUFFIX = f"-{''.join(random.choices(string.ascii_lowercase, k=2))}št" -_PLACEHOLDER_PATTERN = re.compile( - re.escape(_PLACEHOLDER_PREFIX) + r"(\d+)" + re.escape(_PLACEHOLDER_SUFFIX) -) +def make_placeholder_config() -> PlaceholderConfig: + prefix = f"tš{''.join(random.choices(string.ascii_lowercase, k=2))}-" + suffix = f"-{''.join(random.choices(string.ascii_lowercase, k=2))}št" + return PlaceholderConfig( + prefix=prefix, + suffix=suffix, + pattern=re.compile(re.escape(prefix) + r"(\d+)" + re.escape(suffix)), + ) -def make_placeholder(i: int) -> str: - """Generate a placeholder for the i-th interpolation.""" - return f"{_PLACEHOLDER_PREFIX}{i}{_PLACEHOLDER_SUFFIX}" +@dataclass(frozen=True) +class PlaceholderConfig: + """String operations for working with a placeholder pattern.""" + prefix: str + suffix: str + pattern: re.Pattern -def match_placeholders(s: str) -> list[re.Match[str]]: - """Find all placeholders in a string.""" - return list(_PLACEHOLDER_PATTERN.finditer(s)) + def make_placeholder(self, i: int) -> str: + """Generate a placeholder for the i-th interpolation.""" + return f"{self.prefix}{i}{self.suffix}" + def match_placeholders(self, s: str) -> list[re.Match[str]]: + """Find all placeholders in a string.""" + return list(self.pattern.finditer(s)) -def find_placeholders(s: str) -> TemplateRef: - """ - Find all placeholders in a string and return a TemplateRef. + def find_placeholders(self, s: str) -> TemplateRef: + """ + Find all placeholders in a string and return a TemplateRef. - If no placeholders are found, returns a static TemplateRef. - """ - matches = match_placeholders(s) - if not matches: - return TemplateRef.literal(s) + If no placeholders are found, returns a static TemplateRef. + """ + matches = self.match_placeholders(s) + if not matches: + return TemplateRef.literal(s) - strings: list[str] = [] - i_indexes: list[int] = [] - last_index = 0 - for match in matches: - start, end = match.span() - strings.append(s[last_index:start]) - i_indexes.append(int(match[1])) - last_index = end - strings.append(s[last_index:]) + strings: list[str] = [] + i_indexes: list[int] = [] + last_index = 0 + for match in matches: + start, end = match.span() + strings.append(s[last_index:start]) + i_indexes.append(int(match[1])) + last_index = end + strings.append(s[last_index:]) - return TemplateRef(tuple(strings), tuple(i_indexes)) + return TemplateRef(tuple(strings), tuple(i_indexes)) +@dataclass class PlaceholderState: - known: set[int] + known: set[int] = field(default_factory=set) + config: PlaceholderConfig = field(default_factory=make_placeholder_config) """Collection of currently 'known and active' placeholder indexes.""" - def __init__(self): - self.known = set() - @property def is_empty(self) -> bool: return len(self.known) == 0 def add_placeholder(self, index: int) -> str: - placeholder = make_placeholder(index) + placeholder = self.config.make_placeholder(index) self.known.add(index) return placeholder @@ -69,7 +78,7 @@ def remove_placeholders(self, text: str) -> TemplateRef: If no placeholders are found, returns a static PlaceholderRef. """ - pt = find_placeholders(text) + pt = self.config.find_placeholders(text) for index in pt.i_indexes: if index not in self.known: raise ValueError(f"Unknown placeholder index {index} found in text.") diff --git a/tdom/placeholders_test.py b/tdom/placeholders_test.py index 1f6b93c..80a7e8b 100644 --- a/tdom/placeholders_test.py +++ b/tdom/placeholders_test.py @@ -1,52 +1,52 @@ import pytest from .placeholders import ( - _PLACEHOLDER_PREFIX, - _PLACEHOLDER_SUFFIX, + make_placeholder_config, PlaceholderState, - find_placeholders, - make_placeholder, - match_placeholders, ) def test_make_placeholder() -> None: - assert make_placeholder(0) == f"{_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}" - assert make_placeholder(42) == f"{_PLACEHOLDER_PREFIX}42{_PLACEHOLDER_SUFFIX}" + config = make_placeholder_config() + assert config.make_placeholder(0) == f"{config.prefix}0{config.suffix}" + assert config.make_placeholder(42) == f"{config.prefix}42{config.suffix}" def test_match_placeholders() -> None: - s = f"Start {_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX} middle {_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX} end" - matches = match_placeholders(s) + config = make_placeholder_config() + s = f"Start {config.prefix}0{config.suffix} middle {config.prefix}1{config.suffix} end" + matches = config.match_placeholders(s) assert len(matches) == 2 - assert matches[0].group(0) == f"{_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}" + assert matches[0].group(0) == f"{config.prefix}0{config.suffix}" assert matches[0][1] == "0" - assert matches[1].group(0) == f"{_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX}" + assert matches[1].group(0) == f"{config.prefix}1{config.suffix}" assert matches[1][1] == "1" def test_find_placeholders() -> None: - s = f"Hello {_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}, today is {_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX}." - pt = find_placeholders(s) + config = make_placeholder_config() + s = f"Hello {config.prefix}0{config.suffix}, today is {config.prefix}1{config.suffix}." + pt = config.find_placeholders(s) assert pt.strings == ("Hello ", ", today is ", ".") assert pt.i_indexes == (0, 1) literal_s = "No placeholders here." - literal_pt = find_placeholders(literal_s) + literal_pt = config.find_placeholders(literal_s) assert literal_pt.strings == (literal_s,) assert literal_pt.i_indexes == () def test_placeholder_state() -> None: - state = PlaceholderState() + config = make_placeholder_config() + state = PlaceholderState(config=config) assert state.is_empty p0 = state.add_placeholder(0) - assert p0 == make_placeholder(0) + assert p0 == config.make_placeholder(0) assert not state.is_empty p1 = state.add_placeholder(1) - assert p1 == make_placeholder(1) + assert p1 == config.make_placeholder(1) text = f"Values: {p0}, {p1}" pt = state.remove_placeholders(text) @@ -55,4 +55,4 @@ def test_placeholder_state() -> None: assert state.is_empty with pytest.raises(ValueError): - state.remove_placeholders(f"Unknown placeholder: {make_placeholder(2)}") + state.remove_placeholders(f"Unknown placeholder: {config.make_placeholder(2)}") diff --git a/tdom/processor.py b/tdom/processor.py index 6641026..e3773a9 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,13 +1,13 @@ import sys import typing as t -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from functools import lru_cache from string.templatelib import Interpolation, Template +from dataclasses import dataclass from markupsafe import Markup from .callables import get_callable_info -from .classnames import classnames from .format import format_interpolation as base_format_interpolation from .format import format_template from .nodes import Comment, DocumentType, Element, Fragment, Node, Text @@ -94,8 +94,10 @@ def _force_dict(value: t.Any, *, kind: str) -> dict: ) from None -def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: +def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: """Produce aria-* attributes based on the interpolated value for "aria".""" + if value is None: + return d = _force_dict(value, kind="aria") for sub_k, sub_v in d.items(): if sub_v is True: @@ -103,39 +105,23 @@ def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: elif sub_v is False: yield f"aria-{sub_k}", "false" elif sub_v is None: - pass + yield f"aria-{sub_k}", None else: yield f"aria-{sub_k}", str(sub_v) -def _process_data_attr(value: object) -> t.Iterable[Attribute]: +def _expand_data_attr(value: object) -> t.Iterable[Attribute]: """Produce data-* attributes based on the interpolated value for "data".""" + if value is None: + return d = _force_dict(value, kind="data") for sub_k, sub_v in d.items(): - if sub_v is True: - yield f"data-{sub_k}", True - elif sub_v is not False and sub_v is not None: + if sub_v is True or sub_v is False or sub_v is None: + yield f"data-{sub_k}", sub_v + else: yield f"data-{sub_k}", str(sub_v) -def _process_class_attr(value: object) -> t.Iterable[HTMLAttribute]: - """Substitute a class attribute based on the interpolated value.""" - yield ("class", classnames(value)) - - -def _process_style_attr(value: object) -> t.Iterable[HTMLAttribute]: - """Substitute a style attribute based on the interpolated value.""" - if isinstance(value, str): - yield ("style", value) - return - try: - d = _force_dict(value, kind="style") - style_str = "; ".join(f"{k}: {v}" for k, v in d.items()) - yield ("style", style_str) - except TypeError: - raise TypeError("'style' attribute value must be a string or dict") from None - - def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]: """ Substitute a spread attribute based on the interpolated value. @@ -145,35 +131,153 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]: The value must be a dict or iterable of key-value pairs. """ d = _force_dict(value, kind="spread") - for sub_k, sub_v in d.items(): - yield from _process_attr(sub_k, sub_v) + yield from d.items() -# A collection of custom handlers for certain attribute names that have -# special semantics. This is in addition to the special-casing in -# _substitute_attr() itself. -CUSTOM_ATTR_PROCESSORS = { - "class": _process_class_attr, - "data": _process_data_attr, - "style": _process_style_attr, - "aria": _process_aria_attr, +ATTR_EXPANDERS = { + "data": _expand_data_attr, + "aria": _expand_aria_attr, } -def _process_attr(key: str, value: object) -> t.Iterable[Attribute]: +def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]: + """ + Parse the style declarations out of a style attribute string. + """ + props = [p.strip() for p in style_str.split(";")] + styles: list[tuple[str, str | None]] = [] + for prop in props: + if prop: + prop_parts = [p.strip() for p in prop.split(":") if p.strip()] + if len(prop_parts) != 2: + raise ValueError( + f"Invalid number of parts for style property {prop} in {style_str}" + ) + styles.append((prop_parts[0], prop_parts[1])) + return styles + + +def make_style_accumulator(old_value: object) -> StyleAccumulator: """ - Substitute a single attribute based on its key and the interpolated value. + Initialize the style accumulator. + """ + match old_value: + case str(): + styles = { + name: value for name, value in parse_style_attribute_value(old_value) + } + case True: # A bare attribute will just default to {}. + styles = {} + case _: + raise TypeError(f"Unexpected value: {old_value}") + return StyleAccumulator(styles=styles) + + +@dataclass +class StyleAccumulator: + styles: dict[str, str | None] - A single parsed attribute with a placeholder may result in multiple - attributes in the final output, for instance if the value is a dict or - iterable of key-value pairs. Likewise, a value of False will result in - the attribute being omitted entirely; nothing is yielded in that case. + def merge_value(self, value: object) -> None: + """ + Merge in an interpolated style value. + """ + match value: + case str(): + self.styles.update( + {name: value for name, value in parse_style_attribute_value(value)} + ) + case dict(): + self.styles.update( + { + str(pn): str(pv) if pv is not None else None + for pn, pv in value.items() + } + ) + case None: + pass + case _: + raise TypeError( + f"Unknown interpolated style value {value}, use '' to omit." + ) + + def to_value(self) -> str | None: + """ + Serialize the special style value back into a string. + + @NOTE: If the result would be `''` then use `None` to omit the attribute. + """ + style_value = "; ".join( + [f"{pn}: {pv}" for pn, pv in self.styles.items() if pv is not None] + ) + return style_value if style_value else None + + +def make_class_accumulator(old_value: object) -> ClassAccumulator: """ - # Special handling for certain attribute names that have special semantics - if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key): - yield from custom_processor(value) - return - yield (key, value) + Initialize the class accumulator. + """ + match old_value: + case str(): + toggled_classes = {cn: True for cn in old_value.split()} + case True: + toggled_classes = {} + case _: + raise ValueError(f"Unexpected value {old_value}") + return ClassAccumulator(toggled_classes=toggled_classes) + + +@dataclass +class ClassAccumulator: + toggled_classes: dict[str, bool] + + def merge_value(self, value: object) -> None: + """ + Merge in an interpolated class value. + """ + if isinstance(value, dict): + self.toggled_classes.update( + {str(cn): bool(toggle) for cn, toggle in value.items()} + ) + else: + if not isinstance(value, str) and isinstance(value, Sequence): + items = value[:] + else: + items = (value,) + for item in items: + match item: + case str(): + self.toggled_classes.update({cn: True for cn in item.split()}) + case None: + pass + case _: + if item == value: + raise TypeError( + f"Unknown interpolated class value: {value}" + ) + else: + raise TypeError( + f"Unknown interpolated class item in {value}: {item}" + ) + + def to_value(self) -> str | None: + """ + Serialize the special class value back into a string. + + @NOTE: If the result would be `''` then use `None` to omit the attribute. + """ + class_value = " ".join( + [cn for cn, toggle in self.toggled_classes.items() if toggle] + ) + return class_value if class_value else None + + +ATTR_ACCUMULATOR_MAKERS = { + "class": make_class_accumulator, + "style": make_style_accumulator, +} + + +type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator def _resolve_t_attrs( @@ -186,26 +290,61 @@ def _resolve_t_attrs( in a later step. """ new_attrs: AttributesDict = LastUpdatedOrderedDict() + attr_accs: dict[str, AttributeValueAccumulator] = {} for attr in attrs: match attr: case TLiteralAttribute(name=name, value=value): - new_attrs[name] = True if value is None else value + attr_value = True if value is None else value + if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs: + if name not in attr_accs: + attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs[name]) + new_attrs[name] = attr_accs[name].merge_value(attr_value) + else: + new_attrs[name] = attr_value case TInterpolatedAttribute(name=name, value_i_index=i_index): interpolation = interpolations[i_index] attr_value = format_interpolation(interpolation) - for sub_k, sub_v in _process_attr(name, attr_value): - new_attrs[sub_k] = sub_v + if name in ATTR_ACCUMULATOR_MAKERS: + if name not in attr_accs: + attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( + new_attrs.get(name, True) + ) + new_attrs[name] = attr_accs[name].merge_value(attr_value) + elif expander := ATTR_EXPANDERS.get(name): + for sub_k, sub_v in expander(attr_value): + new_attrs[sub_k] = sub_v + else: + new_attrs[name] = attr_value case TTemplatedAttribute(name=name, value_ref=ref): attr_t = _resolve_ref(ref, interpolations) attr_value = format_template(attr_t) - new_attrs[name] = attr_value + if name in ATTR_ACCUMULATOR_MAKERS: + if name not in attr_accs: + attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name]( + new_attrs.get(name, True) + ) + new_attrs[name] = attr_accs[name].merge_value(attr_value) + else: + new_attrs[name] = attr_value case TSpreadAttribute(i_index=i_index): interpolation = interpolations[i_index] spread_value = format_interpolation(interpolation) for sub_k, sub_v in _substitute_spread_attrs(spread_value): - new_attrs[sub_k] = sub_v + if sub_k in ATTR_ACCUMULATOR_MAKERS: + if sub_k not in attr_accs: + attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k]( + new_attrs.get(sub_k, True) + ) + new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v) + elif expander := ATTR_EXPANDERS.get(sub_k): + for exp_k, exp_v in expander(sub_v): + new_attrs[exp_k] = exp_v + else: + new_attrs[sub_k] = sub_v case _: raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}") + for acc_name, acc in attr_accs.items(): + new_attrs[acc_name] = acc.to_value() return new_attrs diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 9f2c289..30a4ed5 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -7,7 +7,7 @@ from markupsafe import Markup from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .placeholders import _PLACEHOLDER_PREFIX, _PLACEHOLDER_SUFFIX +from .placeholders import make_placeholder_config from .processor import html # -------------------------------------------------------------------------- @@ -507,14 +507,31 @@ def test_multiple_attribute_spread_dicts(): def test_interpolated_class_attribute(): - classes = ["btn", "btn-primary", False and "disabled", None, {"active": True}] - node = html(t'') + class_list = ["btn", "btn-primary", "one two", None] + class_dict = {"active": True, "btn-secondary": False} + class_str = "blue" + class_space_sep_str = "green yellow" + class_none = None + class_empty_list = [] + class_empty_dict = {} + button_t = ( + t"" + ) + node = html(button_t) assert node == Element( "button", - attrs={"class": "btn btn-primary active"}, + attrs={"class": "red btn btn-primary one two active blue green yellow"}, children=[Text("Click me")], ) - assert str(node) == '' + assert ( + str(node) + == '' + ) def test_interpolated_class_attribute_with_multiple_placeholders(): @@ -597,25 +614,25 @@ def test_interpolated_attribute_value_tricky_multiple_placeholders(): def test_placeholder_collision_avoidance(): + config = make_placeholder_config() # This test is to ensure that our placeholder detection avoids collisions # even with content that might look like a placeholder. tricky = "123" template = Template( '', ) node = html(template) assert node == Element( "div", - attrs={"data-tricky": _PLACEHOLDER_PREFIX + tricky + _PLACEHOLDER_SUFFIX}, + attrs={"data-tricky": config.prefix + tricky + config.suffix}, children=[], ) assert ( - str(node) - == f'' + str(node) == f'' ) @@ -689,7 +706,35 @@ def test_interpolated_aria_attributes(): ) -def test_interpolated_style_attribute(): +def test_special_aria_none(): + button_aria = None + node = html(t"") + assert node == Element("button", children=[Text("X")]) + assert str(node) == "" + + +def test_special_data_none(): + button_data = None + node = html(t"") + assert node == Element("button", children=[Text("X")]) + assert str(node) == "" + + +# +# Special style attribute handling. +# +def test_style_in_literal_attr(): + p_id = "para1" # non-literal attribute to cause attr resolution + node = html(t'Warning!
') + assert node == Element( + "p", + attrs={"style": "color: red", "id": "para1"}, + children=[Text("Warning!")], + ) + assert str(node) == 'Warning!
' + + +def test_style_in_interpolated_attr(): styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} node = html(t"Warning!
") assert node == Element( @@ -703,29 +748,93 @@ def test_interpolated_style_attribute(): ) -def test_override_static_style_str_str(): - node = html(t'') - assert node == Element("p", {"style": "font-size: 15px"}) - assert str(node) == '' +def test_style_in_templated_attr(): + color = "red" + node = html(t'Warning!
') + assert node == Element( + "p", + attrs={"style": "color: red"}, + children=[Text("Warning!")], + ) + assert str(node) == 'Warning!
' + + +def test_style_in_spread_attr(): + attrs = {"style": {"color": "red"}} + node = html(t"Warning!
") + assert node == Element( + "p", + attrs={"style": "color: red"}, + children=[Text("Warning!")], + ) + assert str(node) == 'Warning!
' + + +def test_style_merged_from_all_attrs(): + attrs = dict(style="font-size: 15px") + style = {"font-weight": "bold"} + color = "red" + node = html( + t'' + ) + assert node == Element( + "p", + {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, + ) + assert ( + str(node) + == '' + ) -def test_override_static_style_str_builder(): - node = html(t'') - assert node == Element("p", {"style": "font-size: 15px"}) - assert str(node) == '' +def test_style_override_left_to_right(): + suffix = t">" + parts = [ + (t'' def test_interpolated_style_attribute_multiple_placeholders(): styles1 = {"color": "red"} styles2 = {"font-weight": "bold"} - node = html(t"Warning!
") # CONSIDER: Is this what we want? Currently, when we have multiple - # placeholders in a single attribute, we treat it as a string attribute. + # placeholders in a single attribute, we treat it as a string attribute + # which produces an invalid style attribute. + with pytest.raises(ValueError): + _ = html(t"Warning!
") + + +def test_interpolated_style_attribute_merged(): + styles1 = {"color": "red"} + styles2 = {"font-weight": "bold"} + node = html(t"Warning!
") + assert node == Element( + "p", + attrs={"style": "color: red; font-weight: bold"}, + children=[Text("Warning!")], + ) + assert str(node) == 'Warning!
' + + +def test_interpolated_style_attribute_merged_override(): + styles1 = {"color": "red", "font-weight": "normal"} + styles2 = {"font-weight": "bold"} + node = html(t"Warning!
") assert node == Element( "p", - attrs={"style": "{'color': 'red'} {'font-weight': 'bold'}"}, + attrs={"style": "color: red; font-weight: bold"}, children=[Text("Warning!")], ) + assert str(node) == 'Warning!
' def test_style_attribute_str(): @@ -733,10 +842,10 @@ def test_style_attribute_str(): node = html(t"Warning!
") assert node == Element( "p", - attrs={"style": "color: red; font-weight: bold;"}, + attrs={"style": "color: red; font-weight: bold"}, children=[Text("Warning!")], ) - assert str(node) == 'Warning!
' + assert str(node) == 'Warning!
' def test_style_attribute_non_str_non_dict():