diff --git a/README.md b/README.md index 2265d27..1bdd58a 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,7 @@ T-strings work just like f-strings but use a `t` prefix and instead of strings. Once you have a `Template`, you can call this package's `html()` function to -convert it into a tree of `Node` objects that represent your HTML structure. -From there, you can render it to a string, manipulate it programmatically, or -compose it with other templates for maximum flexibility. +render it to a string. ### Getting Started @@ -53,7 +51,6 @@ Import the `html` function and start creating templates: ```python from tdom import html greeting = html(t"

Hello, World!

") -print(type(greeting)) # print(greeting) #

Hello, World!

``` @@ -145,7 +142,7 @@ classes: ```python classes = {"btn-primary": True, "btn-secondary": False} button = html(t'') -assert str(button) == '' +assert button == '' ``` #### The `style` Attribute @@ -166,7 +163,7 @@ 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

' +assert para == '

Important text

' ``` #### The `data` and `aria` Attributes @@ -332,11 +329,13 @@ content and attributes. Use these like custom HTML elements in your templates. The basic form of all component functions is: ```python +from string.templatelib import Template + from typing import Any, Iterable -from tdom import Node, html +from tdom import html -def MyComponent(children: Iterable[Node], **attrs: Any) -> Node: - return html(t"
Cool: {children}
") +def MyComponent(children: Template, **attrs: Any) -> Template: + return t"
Cool: {children}
" ``` To _invoke_ your component within an HTML template, use the special @@ -351,11 +350,13 @@ Because attributes are passed as keyword arguments, you can explicitly provide type hints for better editor support: ```python +from string.templatelib import Template + from typing import Any -from tdom import Node, html +from tdom import html -def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node: - return html(t'{text}: {data_value}') +def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template: + return t'{text}: {data_value}' result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />') # Example: 42 @@ -380,23 +381,7 @@ def Greeting(name: str) -> Template: return t"

Hello, {name}!

" result = html(t"<{Greeting} name='Alice' />") -assert str(result) == "

Hello, Alice!

" -``` - -You may also return an iterable: - - - -```python -from typing import Iterable - -def Items() -> Iterable[Template]: - return [t"
  • first
  • ", t"
  • second
  • "] - -result = html(t"") -assert str(result) == "" +assert result == "

    Hello, Alice!

    " ``` #### Class-based components @@ -410,24 +395,25 @@ One particularly useful pattern is to build class-based components with dataclasses: ```python +from string.templatelib import Template from dataclasses import dataclass, field from typing import Any, Iterable -from tdom import Node, html +from tdom import html @dataclass class Card: - children: Iterable[Node] + children: Template title: str subtitle: str | None = None - def __call__(self) -> Node: - return html(t""" + def __call__(self) -> Template: + return t"""

    {self.title}

    {self.subtitle and t'

    {self.subtitle}

    '}
    {self.children}
    - """) + """ result = html(t"<{Card} title='My Card' subtitle='A subtitle'>

    Card content

    ") #
    @@ -452,7 +438,8 @@ syntax as HTML. You can create inline SVG graphics by simply including SVG tags in your templates: ```python @@ -462,24 +449,24 @@ icon = html(t""" """) -assert ' Node: - return html(t""" +def Icon(*, size: int = 24, color: str = "currentColor") -> Template: + return t""" - """) + """ result = html(t'<{Icon} size={48} color="blue" />') -assert 'width="48"' in str(result) -assert 'stroke="blue"' in str(result) +assert 'width="48"' in result +assert 'stroke="blue"' in result ``` #### Context @@ -497,14 +484,14 @@ options: ```python theme = {"primary": "blue", "spacing": "10px"} -def Button(text: str) -> Node: +def Button(text: str) -> Template: # Button has access to theme from enclosing scope - return html(t'') + return t'' result = html(t'<{Button} text="Click me" />') -assert 'color: blue' in str(result) -assert 'margin: 10px' in str(result) -assert '>Click me' in str(result) +assert 'color: blue' in result +assert 'margin: 10px' in result +assert '>Click me' in result ``` 3. **Use module-level or global state**: For truly application-wide @@ -518,41 +505,6 @@ This explicit approach makes it clear where data comes from and avoids the ### The `tdom` Module -#### Working with `Node` Objects - -While `html()` is the primary way to create nodes, you can also construct them -directly for programmatic HTML generation: - -```python -from tdom import Element, Text, Fragment, Comment, DocumentType - -# Create elements directly -div = Element("div", attrs={"class": "container"}, children=[ - Text("Hello, "), - Element("strong", children=[Text("World")]), -]) -assert str(div) == '
    Hello, World
    ' - -# Create fragments to group multiple nodes -fragment = Fragment(children=[ - Element("h1", children=[Text("Title")]), - Element("p", children=[Text("Paragraph")]), -]) -assert str(fragment) == "

    Title

    Paragraph

    " - -# Add comments -page = Element("body", children=[ - Comment("Navigation section"), - Element("nav", children=[Text("Nav content")]), -]) -assert str(page) == "" -``` - -All nodes implement the `__html__()` protocol, which means they can be used -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. - #### Utilities The `tdom` package includes several utility functions for working with diff --git a/docs/usage/components.md b/docs/usage/components.md index cc341d4..5e7f04d 100644 --- a/docs/usage/components.md +++ b/docs/usage/components.md @@ -25,11 +25,11 @@ function with normal Python arguments and return values. ## Simple Heading Here is a component callable — a `Heading` function — which returns -a `Node`: +a `Template`: @@ -39,7 +39,7 @@ def Heading() -> Template: result = html(t"<{Heading} />") -assert str(result) == '

    My Title

    ' +assert result == '

    My Title

    ' ``` ## Simple Props @@ -54,7 +54,7 @@ def Heading(title: str) -> Template: result = html(t'<{Heading} title="My Title">') -assert str(result) == '

    My Title

    ' +assert result == '

    My Title

    ' ``` ## Children As Props @@ -63,32 +63,29 @@ If your template has children inside the component element, your component will receive them as a keyword argument: ```python -def Heading(children: Iterable[Node], title: str) -> Node: - return html(t"

    {title}

    {children}
    ") +def Heading(children: Template, title: str) -> Template: + return t"

    {title}

    {children}
    " result = html(t'<{Heading} title="My Title">Child') -assert str(result) == '

    My Title

    Child
    ' +assert result == '

    My Title

    Child
    ' ``` Note how the component closes with `` when it contains nested children, as opposed to the self-closing form in the first example. If no children are provided, the value of children is an empty tuple. -Note also that components functions can return `Node` or `Template` values as -they wish. Iterables of nodes and templates are also supported. - The component does not have to list a `children` keyword argument. If it is omitted from the function parameters and passed in by the usage, it is silently ignored: ```python -def Heading(title: str) -> Node: - return html(t"

    {title}

    Ignore the children.
    ") +def Heading(title: str) -> Template: + return t"

    {title}

    Ignore the children.
    " result = html(t'<{Heading} title="My Title">Child') -assert str(result) == '

    My Title

    Ignore the children.
    ' +assert result == '

    My Title

    Ignore the children.
    ' ``` ## Optional Props @@ -102,7 +99,7 @@ def Heading(title: str = "My Title") -> Template: result = html(t"<{Heading} />") -assert str(result) == '

    My Title

    ' +assert result == '

    My Title

    ' ``` ## Passsing Another Component as a Prop @@ -121,7 +118,7 @@ def Body(heading: Callable) -> Template: result = html(t"<{Body} heading={DefaultHeading} />") -assert str(result) == '

    Default Heading

    ' +assert result == '

    Default Heading

    ' ``` ## Default Component for Prop @@ -139,11 +136,11 @@ def OtherHeading() -> Template: def Body(heading: Callable) -> Template: - return html(t"<{heading} />") + return t"<{heading} />" result = html(t"<{Body} heading={OtherHeading}>") -assert str(result) == '

    Other Heading

    ' +assert result == '

    Other Heading

    ' ``` ## Conditional Default @@ -165,23 +162,7 @@ def Body(heading: Callable | None = None) -> Template: result = html(t"<{Body} heading={OtherHeading}>") -assert str(result) == '

    Other Heading

    ' -``` - -## Generators as Components - -You can also have components that act as generators. For example, imagine you -have a todo list. There might be a lot of todos, so you want to generate them in -a memory-efficient way: - -```python -def Todos() -> Iterable[Template]: - for todo in ["first", "second", "third"]: - yield t"
  • {todo}
  • " - - -result = html(t"
      <{Todos} />
    ") -assert str(result) == '
    • first
    • second
    • third
    ' +assert result == '

    Other Heading

    ' ``` ## Nested Components @@ -200,5 +181,5 @@ def TodoList(labels: Iterable[str]) -> Template: title = "My Todos" labels = ["first", "second", "third"] result = html(t"

    {title}

    <{TodoList} labels={labels} />") -assert str(result) == '

    My Todos

    • first
    • second
    • third
    ' +assert result == '

    My Todos

    • first
    • second
    • third
    ' ``` diff --git a/docs/usage/looping.md b/docs/usage/looping.md index 5b9b80b..ae91c36 100644 --- a/docs/usage/looping.md +++ b/docs/usage/looping.md @@ -25,7 +25,7 @@ result = html( """ ) -assert str(result) == """ +assert result == """
    • World
    • Universe
    @@ -35,12 +35,12 @@ assert str(result) == """ ## Rendered Looping You could also move the generation of the items out of the "parent" template, -then use that `Node` result in the next template: +then use that result in the next template: ```python message = "Hello" names = ["World", "Universe"] -items = [html(t"
  • {label}
  • ") for label in names] +items = [t"
  • {label}
  • " for label in names] result = html(t"
      {items}
    ") -assert str(result) == '
    • World
    • Universe
    ' +assert result == '
    • World
    • Universe
    ' ``` diff --git a/docs/usage/static_string.md b/docs/usage/static_string.md index 4f869d3..9237677 100644 --- a/docs/usage/static_string.md +++ b/docs/usage/static_string.md @@ -8,19 +8,18 @@ Let's start with the simplest form of templating: just a string, no tags, no attributes: ```python result = html(t"Hello World") -assert str(result) == 'Hello World' +assert result == 'Hello World' ``` We start by importing the `html` function from `tdom`. It takes a [Python 3.14 t-string](https://t-strings.help/introduction.html) and -returns a `Element` with an `__str__` that converts to HTML. In this case, the -node is an instance of `tdom.nodes.Text`, a subclass of `Element`. +returns a `str()`. ## Simple Render @@ -29,32 +28,9 @@ but done in one step: ```python result = html(t"
    Hello World
    ") -assert str(result) == '
    Hello World
    ' +assert result == '
    Hello World
    ' ``` -## Show the `Element` Itself - -Let's take a look at that `Element` structure. - -This time, we'll inspect the returned value rather than rendering it to a -string: - -```python -result = html(t'
    Hello World
    ') -assert result == Element( - "div", - attrs={"class": "container"}, - children=[Text("Hello World")] -) -``` - -In our test we see that we got back an `Element`. What does it look like? - -- The `result` is of type `tdom.nodes.Element` (a subclass of `Node`) -- The name of the node (`
    `) -- The properties passed to that tag (in this case, `{"class": "container"}`) -- The children of this tag (in this case, a `Text` node of `Hello World`) - ## Interpolations as Attribute Values We can go one step further with this and use interpolations from PEP 750 @@ -64,36 +40,13 @@ braces: ```python my_class = "active" result = html(t'
    Hello World
    ') -assert str(result) == '
    Hello World
    ' +assert result == '
    Hello World
    ' ``` TODO: describe all the many many ways to express attribute values, including `tdom`'s special handling of boolean attributes, whole-tag spreads, `class`, `style`, `data` and `aria` attributes, etc. -## Child Nodes in an `Element` - -Let's look at what more nesting would look like: - -```python -result = html(t"
    Hello World!
    ") -assert result == Element( - "div", - children=[ - Text("Hello "), - Element( - "span", - children=[ - Text("World"), - Element("em", children=[Text("!")]) - ] - ) - ] -) -``` - -It's a nested Python datastructure -- pretty simple to look at. - ## Expressing the Document Type One last point: the HTML doctype can be a tricky one to get into the template. @@ -101,7 +54,7 @@ In `tdom` this is straightforward: ```python result = html(t"
    Hello World
    ") -assert str(result) == '
    Hello World
    ' +assert result == '
    Hello World
    ' ``` ## Reducing Boolean Attribute Values @@ -112,5 +65,5 @@ without a _value_: ```python result = html(t"
    Hello World
    ") -assert str(result) == '
    Hello World
    ' +assert result == '
    Hello World
    ' ``` diff --git a/tdom/__init__.py b/tdom/__init__.py index a180b51..8679eae 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,18 +1,11 @@ from markupsafe import Markup, escape -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text from .processor import html, svg # We consider `Markup` and `escape` to be part of this module's public API __all__ = [ - "Comment", - "DocumentType", - "Element", - "Fragment", "Markup", - "Node", - "Text", "escape", "html", "svg", diff --git a/tdom/escaping.py b/tdom/escaping.py index d23ce65..6b6c1ff 100644 --- a/tdom/escaping.py +++ b/tdom/escaping.py @@ -2,6 +2,8 @@ from markupsafe import escape as markup_escape +from .protocols import HasHTMLDunder + escape_html_text = markup_escape # unify api for test of project @@ -9,10 +11,19 @@ LT = "<" -def escape_html_comment(text: str) -> str: +def escape_html_comment(text: str, allow_markup: bool = False) -> str: """Escape text injected into an HTML comment.""" if not text: return text + if allow_markup and isinstance(text, HasHTMLDunder): + return text.__html__() + + if not allow_markup and type(text) is not str: + # String manipulation triggers regular html escapes on Markup + # so we coerce the subclass of `str` into a true `str` before + # we start string manipulating. + text = str(text) + # - text must not start with the string ">" if text[0] == ">": text = GT + text[1:] @@ -44,8 +55,10 @@ def escape_html_comment(text: str) -> str: ) -def escape_html_style(text: str) -> str: +def escape_html_style(text: str, allow_markup: bool = False) -> str: """Escape text injected into an HTML style element.""" + if allow_markup and isinstance(text, HasHTMLDunder): + return text.__html__() for matche_re, replace_text in STYLE_RES: text = re.sub(matche_re, replace_text, text) return text @@ -70,7 +83,7 @@ def escape_html_style(text: str) -> str: ) -def escape_html_script(text: str) -> str: +def escape_html_script(text: str, allow_markup: bool = False) -> str: """ Escape text injected into an HTML script element. @@ -83,6 +96,8 @@ def escape_html_script(text: str) -> str: - " None: + assert escape_html_text("
    ") == "<div>" def test_escape_html_comment_empty() -> None: @@ -27,6 +38,15 @@ def test_escape_html_comment_ends_with_lt_dash() -> None: assert escape_html_comment("This is a comment None: + input_text = "-->" + escaped_text = "-->" + out = escape_html_comment(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_comment(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_style() -> None: input_text = "body { color: red; } p { font-SIZE: 12px; }" expected_output = ( @@ -35,6 +55,15 @@ def test_escape_html_style() -> None: assert escape_html_style(input_text) == expected_output +def test_escape_html_style_markup() -> None: + input_text = "" + escaped_text = "</STYLE>" + out = escape_html_style(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_style(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_script() -> None: input_text = "" - - -@dataclass(slots=True) -class DocumentType(Node): - text: str = "html" - - def __str__(self) -> str: - return f"" - - -@dataclass(slots=True) -class Element(Node): - tag: str - attrs: dict[str, str | None] = field(default_factory=dict) - children: list[Node] = field(default_factory=list) - - def __post_init__(self): - """Ensure all preconditions are met.""" - if not self.tag: - raise ValueError("Element tag cannot be empty.") - - # Void elements cannot have children - if self.is_void and self.children: - raise ValueError(f"Void element <{self.tag}> cannot have children.") - - @property - def is_void(self) -> bool: - return self.tag in VOID_ELEMENTS - - @property - def is_content(self) -> bool: - return self.tag in CONTENT_ELEMENTS - - def _children_to_str(self): - if not self.children: - return "" - if self.tag in ("script", "style"): - chunks = [] - for child in self.children: - if isinstance(child, Text): - chunks.append(child.text) - else: - raise TypeError( - "Cannot serialize non-text content inside a script tag." - ) - raw_children_str = "".join(chunks) - if self.tag == "script": - return escape_html_script(raw_children_str) - elif self.tag == "style": - return escape_html_style(raw_children_str) - else: - raise ValueError("Unsupported tag for single-level bulk escaping.") - else: - return "".join(str(child) for child in self.children) - - def __str__(self) -> str: - # We use markupsafe's escape to handle HTML escaping of attribute values - # which means it's possible to mark them as safe if needed. - attrs_str = "".join( - f" {key}" if value is None else f' {key}="{escape_html_text(value)}"' - for key, value in self.attrs.items() - ) - if self.is_void: - return f"<{self.tag}{attrs_str} />" - if not self.children: - return f"<{self.tag}{attrs_str}>" - children_str = self._children_to_str() - return f"<{self.tag}{attrs_str}>{children_str}" diff --git a/tdom/nodes_test.py b/tdom/nodes_test.py deleted file mode 100644 index ab57c9e..0000000 --- a/tdom/nodes_test.py +++ /dev/null @@ -1,244 +0,0 @@ -import pytest -from markupsafe import Markup - -from .nodes import Comment, DocumentType, Element, Fragment, Text - - -def test_comment(): - comment = Comment("This is a comment") - assert str(comment) == "" - - -def test_comment_empty(): - comment = Comment("") - assert str(comment) == "" - - -def test_comment_special_chars(): - comment = Comment("Special chars: <>&\"'") - assert str(comment) == "" - - -def test_doctype_default(): - doctype = DocumentType() - assert str(doctype) == "" - - -def test_doctype_custom(): - doctype = DocumentType("xml") - assert str(doctype) == "" - - -def test_text(): - text = Text("Hello, world!") - assert str(text) == "Hello, world!" - - -def test_text_escaping(): - text = Text("") - assert str(text) == "<script>alert('XSS')</script>" - - -def test_text_safe(): - class CustomHTML(str): - def __html__(self) -> str: - return "Bold Text" - - text = Text(CustomHTML()) - assert str(text) == "Bold Text" - - -def test_text_equality(): - text1 = Text("") - text2 = Text(Markup("<Hello>")) - text3 = Text(Markup("")) - assert text1 == text2 - assert text1 != text3 - - -def test_fragment_empty(): - fragment = Fragment() - assert str(fragment) == "" - - -def test_fragment_with_text(): - fragment = Fragment(children=[Text("test")]) - assert str(fragment) == "test" - - -def test_fragment_with_multiple_texts(): - fragment = Fragment(children=[Text("Hello"), Text(" "), Text("World")]) - assert str(fragment) == "Hello World" - - -def test_element_no_children(): - div = Element("div") - assert not div.is_void - assert str(div) == "
    " - - -def test_void_element_no_children(): - br = Element("br") - assert br.is_void - assert str(br) == "
    " - - -def test_element_invalid_empty_tag(): - with pytest.raises(ValueError): - _ = Element("") - - -def test_element_is_content(): - assert Element("script").is_content - assert Element("title").is_content - assert not Element("div").is_content - assert not Element("br").is_content # Void element - - -def test_void_element_with_attributes(): - br = Element("br", attrs={"class": "line-break", "hidden": None}) - assert str(br) == '' - - -def test_void_element_with_children(): - with pytest.raises(ValueError): - _ = Element("br", children=[Text("should not be here")]) - - -def test_standard_element_with_attributes(): - div = Element( - "div", - attrs={"id": "main", "data-role": "container", "hidden": None}, - ) - assert str(div) == '' - - -def test_standard_element_with_text_child(): - div = Element("div", children=[Text("Hello, world!")]) - assert str(div) == "
    Hello, world!
    " - - -def test_standard_element_with_element_children(): - div = Element( - "div", - children=[ - Element("h1", children=[Text("Title")]), - Element("p", children=[Text("This is a paragraph.")]), - ], - ) - assert str(div) == "

    Title

    This is a paragraph.

    " - - -def test_element_with_fragment_with_children(): - div = Element( - "div", - children=[ - Fragment( - children=[ - Element("div", children=[Text("wow")]), - Text("inside fragment"), - ] - ) - ], - ) - assert str(div) == "
    wow
    inside fragment
    " - - -def test_standard_element_with_mixed_children(): - div = Element( - "div", - children=[ - Text("Intro text."), - Element("h1", children=[Text("Title")]), - Text("Some more text."), - Element("hr"), - Element("p", children=[Text("This is a paragraph.")]), - ], - ) - assert str(div) == ( - "
    Intro text.

    Title

    Some more text.

    This is a paragraph.

    " - ) - - -def test_complex_tree(): - html = Fragment( - children=[ - DocumentType(), - Element( - "html", - children=[ - Element( - "head", - children=[ - Element("title", children=[Text("Test Page")]), - Element("meta", attrs={"charset": "UTF-8"}), - ], - ), - Element( - "body", - attrs={"class": "main-body"}, - children=[ - Element("h1", children=[Text("Welcome to the Test Page")]), - Element( - "p", - children=[ - Text("This is a sample paragraph with "), - Element("strong", children=[Text("bold text")]), - Text(" and "), - Element("em", children=[Text("italic text")]), - Text("."), - ], - ), - Element("br"), - Element( - "ul", - children=[ - Element("li", children=[Text("Item 1")]), - Element("li", children=[Text("Item 2")]), - Element("li", children=[Text("Item 3")]), - ], - ), - ], - ), - ], - ), - ] - ) - assert str(html) == ( - "Test Page" - '' - "

    Welcome to the Test Page

    " - "

    This is a sample paragraph with bold text and " - "italic text.


    • Item 1
    • Item 2
    • " - "
    • Item 3
    " - ) - - -def test_dunder_html_method(): - div = Element("div", children=[Text("Hello")]) - assert div.__html__() == str(div) - - -def test_escaping_of_text_content(): - div = Element("div", children=[Text("")]) - assert str(div) == "
    <script>alert('XSS')</script>
    " - - -def test_escaping_of_attribute_values(): - div = Element("div", attrs={"class": '">XSS<'}) - assert str(div) == '
    ' - - -def test_svg_rendering(): - svg = Element( - "svg", - attrs={"viewBox": "0 0 10 10"}, - children=[ - Element("clipPath", attrs={"id": "clip"}), - Element("rect", attrs={"x": "0", "y": "0", "width": "10", "height": "10"}), - ], - ) - assert ( - str(svg) - == '' - ) diff --git a/tdom/parser.py b/tdom/parser.py index e1fc743..5ac501b 100644 --- a/tdom/parser.py +++ b/tdom/parser.py @@ -3,7 +3,7 @@ from html.parser import HTMLParser from string.templatelib import Interpolation, Template -from .htmlspec import SVG_ATTR_FIX, SVG_TAG_FIX, VOID_ELEMENTS +from .htmlspec import VOID_ELEMENTS from .placeholders import PlaceholderState from .template_utils import combine_template_refs from .tnodes import ( @@ -86,10 +86,8 @@ class TemplateParser(HTMLParser): stack: list[OpenTag] placeholders: PlaceholderState source: SourceTracker | None - _svg_depth: int = 0 - def __init__(self, *, convert_charrefs: bool = True, svg_context: bool = False): - self._initial_svg_depth = 1 if svg_context else 0 + def __init__(self, *, convert_charrefs: bool = True): # This calls HTMLParser.reset() which we override to set up our state. super().__init__(convert_charrefs=convert_charrefs) @@ -109,12 +107,10 @@ def append_child(self, child: TNode) -> None: # Attribute Helpers # ------------------------------------------ - def make_tattr(self, attr: HTMLAttribute, svg_context: bool = False) -> TAttribute: + def make_tattr(self, attr: HTMLAttribute) -> TAttribute: """Build a TAttribute from a raw attribute tuple.""" name, value = attr - if svg_context: - name = SVG_ATTR_FIX.get(name, name) name_ref = self.placeholders.remove_placeholders(name) value_ref = ( @@ -140,24 +136,20 @@ def make_tattr(self, attr: HTMLAttribute, svg_context: bool = False) -> TAttribu ) return TSpreadAttribute(i_index=name_ref.i_indexes[0]) - def make_tattrs( - self, attrs: Sequence[HTMLAttribute], svg_context: bool = False - ) -> tuple[TAttribute, ...]: + def make_tattrs(self, attrs: Sequence[HTMLAttribute]) -> tuple[TAttribute, ...]: """Build TAttributes from raw attribute tuples.""" - return tuple(self.make_tattr(attr, svg_context) for attr in attrs) + return tuple(self.make_tattr(attr) for attr in attrs) # ------------------------------------------ # Tag Helpers # ------------------------------------------ - def make_open_tag( - self, tag: str, attrs: Sequence[HTMLAttribute], svg_context: bool = False - ) -> OpenTag: + def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag: """Build an OpenTag from a raw tag and attribute tuples.""" tag_ref = self.placeholders.remove_placeholders(tag) if tag_ref.is_literal: - return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs, svg_context)) + return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs)) if not tag_ref.is_singleton: raise ValueError( @@ -170,7 +162,7 @@ def make_open_tag( i_index = tag_ref.i_indexes[0] return OpenTComponent( start_i_index=i_index, - attrs=self.make_tattrs(attrs, svg_context), + attrs=self.make_tattrs(attrs), ) def finalize_tag( @@ -233,13 +225,7 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None: # ------------------------------------------ def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None: - if tag == "svg": - self._svg_depth += 1 - - if self._svg_depth > 0: - tag = SVG_TAG_FIX.get(tag, tag) - - open_tag = self.make_open_tag(tag, attrs, svg_context=(self._svg_depth > 0)) + open_tag = self.make_open_tag(tag, attrs) if isinstance(open_tag, OpenTElement) and open_tag.tag in VOID_ELEMENTS: final_tag = self.finalize_tag(open_tag) self.append_child(final_tag) @@ -248,13 +234,7 @@ def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None: def handle_startendtag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None: """Dispatch a self-closing tag, `` to specialized handlers.""" - is_svg_tag = tag == "svg" - effective_svg_context = (self._svg_depth > 0) or is_svg_tag - - if effective_svg_context: - tag = SVG_TAG_FIX.get(tag, tag) - - open_tag = self.make_open_tag(tag, attrs, svg_context=effective_svg_context) + open_tag = self.make_open_tag(tag, attrs) final_tag = self.finalize_tag(open_tag) self.append_child(final_tag) @@ -262,12 +242,6 @@ def handle_endtag(self, tag: str) -> None: if not self.stack: raise ValueError(f"Unexpected closing tag with no open tag.") - if self._svg_depth > 0: - tag = SVG_TAG_FIX.get(tag, tag) - - if tag == "svg": - self._svg_depth -= 1 - open_tag = self.stack.pop() endtag_i_index = self.validate_end_tag(tag, open_tag) final_tag = self.finalize_tag(open_tag, endtag_i_index) @@ -311,7 +285,6 @@ def reset(self): self.stack = [] self.placeholders = PlaceholderState() self.source = None - self._svg_depth = getattr(self, "_initial_svg_depth", 0) def close(self) -> None: if self.stack: @@ -364,16 +337,13 @@ def feed_template(self, template: Template) -> None: self.feed_str(template.strings[-1]) @staticmethod - def parse(t: Template, *, svg_context: bool = False) -> TNode: + def parse(t: Template) -> TNode: """ Parse a Template containing valid HTML and substitutions and return a TNode tree representing its structure. This cachable structure can later be resolved against actual interpolation values to produce a Node tree. - - Pass ``svg_context=True`` for SVG fragments that have no ```` - wrapper, so that tag and attribute case-fixing applies from the root. """ - parser = TemplateParser(svg_context=svg_context) + parser = TemplateParser() parser.feed_template(t) parser.close() return parser.get_tnode() diff --git a/tdom/processor.py b/tdom/processor.py index e814385..59aab19 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,4 +1,3 @@ -import sys import typing as t from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass @@ -8,12 +7,30 @@ from markupsafe import Markup from .callables import CallableInfo, get_callable_info +from .escaping import ( + escape_html_comment as default_escape_html_comment, +) +from .escaping import ( + escape_html_script as default_escape_html_script, +) +from .escaping import ( + escape_html_style as default_escape_html_style, +) +from .escaping import ( + escape_html_text as default_escape_html_text, +) from .format import format_interpolation as base_format_interpolation from .format import format_template -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text +from .htmlspec import ( + CDATA_CONTENT_ELEMENTS, + DEFAULT_NORMAL_TEXT_ELEMENT, + RCDATA_CONTENT_ELEMENTS, + SVG_ATTR_FIX, + SVG_TAG_FIX, + VOID_ELEMENTS, +) from .parser import ( HTMLAttribute, - HTMLAttributesDict, TAttribute, TComment, TComponent, @@ -28,21 +45,11 @@ TTemplatedAttribute, TText, ) -from .placeholders import TemplateRef from .protocols import HasHTMLDunder -from .template_utils import template_from_parts +from .sentinel import NOT_SET, NotSet +from .template_utils import TemplateRef from .utils import CachableTemplate, LastUpdatedOrderedDict -# TODO: in Ian's original PR, this caching was tethered to the -# TemplateParser. Here, it's tethered to the processor. I suspect we'll -# revisit this soon enough. - - -@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) -def _parse_and_cache(cachable: CachableTemplate) -> TNode: - return TemplateParser.parse(cachable.template, svg_context=cachable.svg_context) - - type Attribute = tuple[str, object] type AttributesDict = dict[str, object] @@ -325,7 +332,7 @@ def _resolve_t_attrs( else: new_attrs[name] = attr_value case TTemplatedAttribute(name=name, value_ref=ref): - attr_t = _resolve_ref(ref, interpolations) + attr_t = ref.resolve(interpolations) attr_value = format_template(attr_t) if name in ATTR_ACCUMULATOR_MAKERS: if name not in attr_accs: @@ -359,82 +366,16 @@ def _resolve_t_attrs( return new_attrs -def _resolve_html_attrs(attrs: AttributesDict) -> HTMLAttributesDict: +def _resolve_html_attrs(attrs: AttributesDict) -> Iterable[HTMLAttribute]: """Resolve attribute values for HTML output.""" - html_attrs: HTMLAttributesDict = {} for key, value in attrs.items(): match value: case True: - html_attrs[key] = None + yield key, None case False | None: pass case _: - html_attrs[key] = str(value) - return html_attrs - - -def _resolve_attrs( - attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] -) -> HTMLAttributesDict: - """ - Substitute placeholders in attributes for HTML elements. - - This is the full pipeline: interpolation + HTML processing. - """ - interpolated_attrs = _resolve_t_attrs(attrs, interpolations) - return _resolve_html_attrs(interpolated_attrs) - - -def _flatten_nodes(nodes: Iterable[Node]) -> list[Node]: - """Flatten a list of Nodes, expanding any Fragments.""" - flat: list[Node] = [] - for node in nodes: - if isinstance(node, Fragment): - flat.extend(node.children) - else: - flat.append(node) - return flat - - -def _substitute_and_flatten_children( - children: Iterable[TNode], interpolations: tuple[Interpolation, ...] -) -> list[Node]: - """Substitute placeholders in a list of children and flatten any fragments.""" - resolved = [_resolve_t_node(child, interpolations) for child in children] - flat = _flatten_nodes(resolved) - return flat - - -def _node_from_value(value: object) -> Node: - """ - Convert an arbitrary value to a Node. - - This is the primary action performed when replacing interpolations in child - content positions. - """ - match value: - case str(): - return Text(value) - case Node(): - return value - case Template(): - return html(value) - # Consider: falsey values, not just False and None? - case False | None: - return Fragment(children=[]) - case Iterable(): - children = [_node_from_value(v) for v in value] - return Fragment(children=children) - case HasHTMLDunder(): - # CONSIDER: should we do this lazily? - return Text(Markup(value.__html__())) - case c if callable(c): - # Treat all callable values in child content positions as if - # they are zero-arg functions that return a value to be rendered. - return _node_from_value(c()) - case _: - # CONSIDER: should we do this lazily? - return Text(str(value)) + yield key, str(value) def _kebab_to_snake(name: str) -> str: @@ -445,8 +386,8 @@ def _kebab_to_snake(name: str) -> str: def _prep_component_kwargs( callable_info: CallableInfo, attrs: AttributesDict, - system_kwargs: dict[str, object], -): + children: Template, +) -> AttributesDict: if callable_info.requires_positional: raise TypeError( "Component callables cannot have required positional arguments." @@ -460,9 +401,8 @@ def _prep_component_kwargs( if snake_name in callable_info.named_params or callable_info.kwargs: kwargs[snake_name] = attr_value - for attr_name, attr_value in system_kwargs.items(): - if attr_name in callable_info.named_params or callable_info.kwargs: - kwargs[attr_name] = attr_value + if "children" in callable_info.named_params or callable_info.kwargs: + kwargs["children"] = children # Check to make sure we've fully satisfied the callable's requirements missing = callable_info.required_named_params - kwargs.keys() @@ -474,135 +414,552 @@ def _prep_component_kwargs( return kwargs -def _invoke_component( - attrs: AttributesDict, - children: list[Node], # TODO: why not TNode, though? - interpolation: Interpolation, -) -> Node: +def serialize_html_attrs( + html_attrs: Iterable[HTMLAttribute], escape: Callable = default_escape_html_text +) -> str: + return "".join( + (f' {k}="{escape(v)}"' if v is not None else f" {k}" for k, v in html_attrs) + ) + + +def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribute]: """ - Invoke a component callable with the provided attributes and children. + Fix the attr name-case of any html attributes on a tag within an SVG namespace. + """ + for k, v in html_attrs: + yield SVG_ATTR_FIX.get(k, k), v - Components are any callable that meets the required calling signature. - Typically, that's a function, but it could also be the constructor or - __call__() method for a class; dataclass constructors match our expected - invocation style. - We validate the callable's signature and invoke it with keyword-only - arguments, then convert the result to a Node. +def make_ctx(parent_tag: str | None = None, ns: str | None = "html"): + return ProcessContext(parent_tag=parent_tag, ns=ns) - Component invocation rules: - 1. All arguments are passed as keywords only. Components cannot require - positional arguments. +@dataclass(frozen=True, slots=True) +class ProcessContext: + # None means unknown not just a missing value. + parent_tag: str | None = None + # None means unknown not just a missing value. + ns: str | None = None - 2. Children are passed via a "children" parameter when: + def copy( + self, + ns: NotSet | str | None = NOT_SET, + parent_tag: NotSet | str | None = NOT_SET, + ): + if isinstance(ns, NotSet): + resolved_ns = self.ns + else: + resolved_ns = ns + if isinstance(parent_tag, NotSet): + resolved_parent_tag = self.parent_tag + else: + resolved_parent_tag = parent_tag + return make_ctx( + parent_tag=resolved_parent_tag, + ns=resolved_ns, + ) - - Child content exists in the template AND - - The callable accepts "children" OR has **kwargs - If no children exist but the callable accepts "children", we pass an - empty tuple. +type FunctionComponent = Callable[..., Template] +type FactoryComponent = Callable[..., ComponentObject] +type ComponentCallable = FunctionComponent | FactoryComponent +type ComponentObject = Callable[[], Template] - 3. All other attributes are converted from kebab-case to snake_case - and passed as keyword arguments if the callable accepts them (or has - **kwargs). Attributes that don't match parameters are silently ignored. - """ - value = format_interpolation(interpolation) - if not callable(value): - raise TypeError( - f"Expected a callable for component invocation, got {type(value).__name__}" - ) - callable_info = get_callable_info(value) +type NormalTextInterpolationValue = ( + None + | bool # to support `showValue and value` idiom + | str + | HasHTMLDunder + | Template + | Iterable[NormalTextInterpolationValue] + | object +) +# Applies to both escapable raw text and raw text. +type RawTextExactInterpolationValue = ( + None + | bool # to support `showValue and value` idiom + | str + | HasHTMLDunder + | object +) +# Applies to both escapable raw text and raw text. +type RawTextInexactInterpolationValue = ( + None + | bool # to support `showValue and value` idiom + | str + | object +) - kwargs = _prep_component_kwargs( - callable_info, attrs, system_kwargs={"children": tuple(children)} - ) - # The cast avoids `call-top-callable` from ty; there *must* be a smarter - # way, though... -Dave - result = t.cast(Callable[..., t.Any], value)(**kwargs) - return _node_from_value(result) +@dataclass(frozen=True) +class ParserService: + def to_tnode(self, template: Template) -> TNode: + return TemplateParser.parse(template) -def _resolve_ref( - ref: TemplateRef, interpolations: tuple[Interpolation, ...] -) -> Template: - resolved = [interpolations[i_index] for i_index in ref.i_indexes] - return template_from_parts(ref.strings, resolved) - - -def _resolve_t_text_ref( - ref: TemplateRef, interpolations: tuple[Interpolation, ...] -) -> Text | Fragment: - """Resolve a TText ref into Text or Fragment by processing interpolations.""" - if ref.is_literal: - return Text(ref.strings[0]) - - parts = [ - Text(part) - if isinstance(part, str) - else _node_from_value(format_interpolation(part)) - for part in _resolve_ref(ref, interpolations) - ] - flat = _flatten_nodes(parts) - - if len(flat) == 1 and isinstance(flat[0], Text): - return flat[0] - - return Fragment(children=flat) - - -def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node: - """Resolve a TNode tree into a Node tree by processing interpolations.""" - match t_node: - case TText(ref=ref): - return _resolve_t_text_ref(ref, interpolations) - case TComment(ref=ref): - comment_t = _resolve_ref(ref, interpolations) - comment = format_template(comment_t) - return Comment(comment) - case TDocumentType(text=text): - return DocumentType(text) - case TFragment(children=children): - resolved_children = _substitute_and_flatten_children( - children, interpolations +@dataclass(frozen=True) +class CachedParserService(ParserService): + @lru_cache(512) # noqa: B019 + def _to_tnode(self, ct: CachableTemplate): + return super().to_tnode(ct.template) + + def to_tnode(self, template: Template): + return self._to_tnode(CachableTemplate(template)) + + +@dataclass(frozen=True) +class ProcessorService: + parser_api: ParserService + + escape_html_text: Callable = default_escape_html_text + + escape_html_comment: Callable = default_escape_html_comment + + escape_html_script: Callable = default_escape_html_script + + escape_html_style: Callable = default_escape_html_style + + slash_void: bool = False # Apply a xhtml-style slash to void html elements. + + uppercase_doctype: bool = False # DOCTYPE vs doctype + + def process_template( + self, root_template: Template, assume_ctx: ProcessContext | None = None + ) -> str: + """ + Process a TDOM compatible template into a string. + """ + if assume_ctx is None: + # @DESIGN: What do we want to do here? Should we assume we are in + # a tag with normal text? + assume_ctx = make_ctx(parent_tag=DEFAULT_NORMAL_TEXT_ELEMENT, ns="html") + root = self.parser_api.to_tnode(root_template) + return self._process_tnode(root_template, assume_ctx, root) + + def _process_tnode( + self, template: Template, last_ctx: ProcessContext, tnode: TNode + ) -> str: + """ + Process a tnode from a template's "t-tree" into a string. + """ + match tnode: + case TDocumentType(text): + return self._process_document_type(last_ctx, text) + case TComment(ref): + return self._process_comment(template, last_ctx, ref) + case TFragment(children): + return self._process_fragment(template, last_ctx, children) + case TComponent(start_i_index, end_i_index, attrs, children): + return self._process_component( + template, last_ctx, attrs, start_i_index, end_i_index + ) + case TElement(tag, attrs, children): + return self._process_element(template, last_ctx, tag, attrs, children) + case TText(ref): + return self._process_texts(template, last_ctx, ref) + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + + def _process_document_type( + self, + last_ctx: ProcessContext, + text: str, + ) -> str: + if last_ctx.ns != "html": + # Nit + raise ValueError( + "Cannot process document type in subtree of a foreign element." + ) + if self.uppercase_doctype: + return f"" + else: + return f"" + + def _process_fragment( + self, + template: Template, + last_ctx: ProcessContext, + children: Iterable[TNode], + ) -> str: + return "".join( + self._process_tnode(template, last_ctx, child) for child in children + ) + + def _process_texts( + self, + template: Template, + last_ctx: ProcessContext, + ref: TemplateRef, + ) -> str: + if last_ctx.parent_tag is None: + raise NotImplementedError( + "We cannot interpolate texts without knowing what tag they are contained in." ) - return Fragment(children=resolved_children) - case TElement(tag=tag, attrs=attrs, children=children): - resolved_attrs = _resolve_attrs(attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations + elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + return self._process_raw_texts(template, last_ctx, ref) + elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: + # We can handle all at once because there are no non-text children and everything must be string-ified. + return self._process_escapable_raw_texts(template, last_ctx, ref) + else: + return self._process_normal_texts(template, last_ctx, ref) + + def _process_comment( + self, + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> str: + """ + Process a comment into a string. + """ + content_str = resolve_text_without_recursion(template, "" + + def _process_element( + self, + template: Template, + last_ctx: ProcessContext, + tag: str, + attrs: tuple[TAttribute, ...], + children: tuple[TNode, ...], + ) -> str: + out: list[str] = [] + if tag == "svg": + our_ctx = last_ctx.copy(parent_tag=tag, ns="svg") + elif tag == "math": + our_ctx = last_ctx.copy(parent_tag=tag, ns="math") + else: + our_ctx = last_ctx.copy(parent_tag=tag) + if our_ctx.ns == "svg": + starttag = endtag = SVG_TAG_FIX.get(tag, tag) + else: + starttag = endtag = tag + out.append(f"<{starttag}") + if attrs: + out.append(self._process_attrs(template, our_ctx, attrs)) + # @TODO: How can we tell if we write out children or not in + # order to self-close in non-html contexts, ie. SVG? + if self.slash_void and tag in VOID_ELEMENTS: + out.append(" />") + else: + out.append(">") + if tag not in VOID_ELEMENTS: + # We were still in SVG but now we default back into HTML + if tag == "foreignobject": + child_ctx = our_ctx.copy(ns="html") + else: + child_ctx = our_ctx + out.extend( + self._process_tnode(template, child_ctx, child) for child in children ) - return Element(tag=tag, attrs=resolved_attrs, children=resolved_children) - case TComponent( - start_i_index=start_i_index, - end_i_index=end_i_index, - attrs=t_attrs, - children=children, + out.append(f"") + return "".join(out) + + def _process_attrs( + self, + template: Template, + last_ctx: ProcessContext, + attrs: tuple[TAttribute, ...], + ) -> str: + """ + Process an element's attributes into a string. + """ + resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) + if last_ctx.ns == "svg": + attrs_str = serialize_html_attrs( + _fix_svg_attrs(_resolve_html_attrs(resolved_attrs)) + ) + else: + attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) + if attrs_str: + return attrs_str + return "" + + def _process_component( + self, + template: Template, + last_ctx: ProcessContext, + attrs: tuple[TAttribute, ...], + start_i_index: int, + end_i_index: int | None, + ) -> str: + """ + Invoke a component and process the result into a string. + """ + body_start_s_index = ( + start_i_index + + 1 + + len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)]) + ) + start_i = template.interpolations[start_i_index] + component_callable = t.cast(ComponentCallable, start_i.value) + if start_i_index != end_i_index and end_i_index is not None: + # @TODO: We should do this during parsing. + children_template = extract_embedded_template( + template, body_start_s_index, end_i_index + ) + if component_callable != template.interpolations[end_i_index].value: + raise TypeError( + "Component callable in start tag must match component callable in end tag." + ) + else: + children_template = t"" + + if not callable(component_callable): + raise TypeError("Component callable must be callable.") + + kwargs = _prep_component_kwargs( + get_callable_info(component_callable), + _resolve_t_attrs(attrs, template.interpolations), + children=children_template, + ) + + result_t = component_callable(**kwargs) + if ( + result_t is not None + and not isinstance(result_t, Template) + and callable(result_t) ): - start_interpolation = interpolations[start_i_index] - end_interpolation = ( - None if end_i_index is None else interpolations[end_i_index] + component_obj = t.cast(ComponentObject, result_t) # ty: ignore[redundant-cast] + result_t = component_obj() + else: + component_obj = None + + if isinstance(result_t, Template): + if result_t.strings == ("",): + # DO NOTHING + return "" + else: + result_root = self.parser_api.to_tnode(result_t) + return self._process_tnode(result_t, last_ctx, result_root) + else: + raise TypeError(f"Unknown component return value: {type(result_t)}") + + def _process_raw_texts( + self, + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> str: + """ + Process the given content into a string as "raw text". + """ + assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS + content = resolve_text_without_recursion( + template, last_ctx.parent_tag, content_ref + ) + if last_ctx.parent_tag == "script": + return self.escape_html_script( + content, + allow_markup=True, ) - resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations + elif last_ctx.parent_tag == "style": + return self.escape_html_style( + content, + allow_markup=True, ) - # HERE ALSO BE DRAGONS: validate matching start/end callables, since - # the underlying TemplateParser cannot do that for us. - if ( - end_interpolation is not None - and end_interpolation.value != start_interpolation.value - ): - raise TypeError("Mismatched component start and end callables.") - return _invoke_component( - attrs=resolved_attrs, - children=resolved_children, - interpolation=start_interpolation, + else: + raise NotImplementedError( + f"Parent tag {last_ctx.parent_tag} is not supported." ) - case _: - raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") + + def _process_escapable_raw_texts( + self, + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> str: + """ + Process the given content into a string as "escapable raw text". + """ + assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS + content = resolve_text_without_recursion( + template, last_ctx.parent_tag, content_ref + ) + return self.escape_html_text(content) + + def _process_normal_texts( + self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef + ): + """ + Process the given context into a string as "normal text". + """ + return "".join( + ( + self.escape_html_text(part) + if isinstance(part, str) + else self._process_normal_text(template, last_ctx, t.cast(int, part)) + ) + for part in content_ref + ) + + def _process_normal_text( + self, + template: Template, + last_ctx: ProcessContext, + values_index: int, + ) -> str: + """ + Process the value of the interpolation into a string as "normal text". + + @NOTE: This is an interpolation that must be formatted to get the value. + """ + value = format_interpolation(template.interpolations[values_index]) + value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast] + return self._process_normal_text_from_value(template, last_ctx, value) + + def _process_normal_text_from_value( + self, + template: Template, + last_ctx: ProcessContext, + value: NormalTextInterpolationValue, + ) -> str: + """ + Process a single value into a string as "normal text". + + @NOTE: This is an actual value and NOT an interpolation. This is meant to be + used when processing an iterable of values as normal text. + """ + if value is None or isinstance(value, bool): + return "" + elif isinstance(value, str): + # @NOTE: This would apply to Markup() but not to a custom object + # implementing HasHTMLDunder. + return self.escape_html_text(value) + elif isinstance(value, Template): + value_root = self.parser_api.to_tnode(value) + return self._process_tnode(value, last_ctx, value_root) + elif isinstance(value, Iterable): + return "".join( + self._process_normal_text_from_value(template, last_ctx, v) + for v in value + ) + elif isinstance(value, HasHTMLDunder): + # @NOTE: markupsafe's escape does this for us but we put this in + # here for completeness. + # @NOTE: An actual Markup() would actually pass as a str() but a + # custom object with __html__ might not. + return Markup(value.__html__()) + else: + # @DESIGN: Everything that isn't an object we recognize is + # coerced to a str() and emitted. + return self.escape_html_text(value) + + +def resolve_text_without_recursion( + template: Template, parent_tag: str, content_ref: TemplateRef +) -> str: + """ + Resolve the text in the given template without recursing into more structured text. + + This can be bypassed by interpolating an exact match with an object with `__html__()`. + + A non-exact match is not allowed because we cannot process escaping + across the boundary between other content and the pass-through content. + """ + if content_ref.is_singleton: + value = format_interpolation(template.interpolations[content_ref.i_indexes[0]]) + value = t.cast(RawTextExactInterpolationValue, value) # ty: ignore[redundant-cast] + if value is None or isinstance(value, bool): + return "" + elif isinstance(value, str): + return value + elif isinstance(value, HasHTMLDunder): + # @DESIGN: We could also force callers to use `:safe` to trigger + # the interpolation in this special case. + return Markup(value.__html__()) + elif isinstance(value, (Template, Iterable)): + raise ValueError( + f"Recursive includes are not supported within {parent_tag}" + ) + else: + return str(value) + else: + text = [] + for part in content_ref: + if isinstance(part, str): + if part: + text.append(part) + continue + value = format_interpolation(template.interpolations[part]) + value = t.cast(RawTextInexactInterpolationValue, value) # ty: ignore[redundant-cast] + if value is None or isinstance(value, bool): + continue + elif ( + type(value) is str + ): # type() check to avoid subclasses, probably something smarter here + if value: + text.append(value) + elif not isinstance(value, str) and isinstance(value, (Template, Iterable)): + raise ValueError( + f"Recursive includes are not supported within {parent_tag}" + ) + elif isinstance(value, HasHTMLDunder): + raise ValueError( + f"Non-exact trusted interpolations are not supported within {parent_tag}" + ) + else: + value_str = str(value) + if value_str: + text.append(value_str) + return "".join(text) + + +def extract_embedded_template( + template: Template, body_start_s_index: int, end_i_index: int +) -> Template: + """ + Extract the template parts exclusively from start tag to end tag. + + Note that interpolations INSIDE the start tag make this more complex + than just "the `s_index` after the component callable's `i_index`". + + Example: + ```python + template = ( + t'<{comp} attr={attr}>' + t'
    {content} {footer}
    ' + t'' + ) + assert extract_children_template(template, 2, 4) == ( + t'
    {content} {footer}
    ' + ) + starttag = t'<{comp} attr={attr}>' + endtag = t'' + assert template == starttag + extract_children_template(template, 2, 4) + endtag + ``` + @DESIGN: "There must be a better way." + """ + # Copy the parts out of the containing template. + index = body_start_s_index + last_s_index = end_i_index + parts = [] + while index <= last_s_index: + parts.append(template.strings[index]) + if index != last_s_index: + parts.append(template.interpolations[index]) + index += 1 + # Now trim the first part to the end of the opening tag. + parts[0] = parts[0][parts[0].find(">") + 1 :] + # Now trim the last part (could also be the first) to the start of the closing tag. + parts[-1] = parts[-1][: parts[-1].rfind("<")] + return Template(*parts) + + +def processor_service_factory(**config_kwargs): + return ProcessorService(parser_api=ParserService(), **config_kwargs) + + +def cached_processor_service_factory(**config_kwargs): + return ProcessorService(parser_api=CachedParserService(), **config_kwargs) + + +_default_processor_api = cached_processor_service_factory( + slash_void=True, uppercase_doctype=True +) # -------------------------------------------------------------------------- @@ -610,15 +967,13 @@ def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> # -------------------------------------------------------------------------- -def html(template: Template) -> Node: - """Parse an HTML t-string, substitute values, and return a tree of Nodes.""" - cachable = CachableTemplate(template) - t_node = _parse_and_cache(cachable) - return _resolve_t_node(t_node, template.interpolations) +def html(template: Template, assume_ctx: ProcessContext | None = None) -> str: + """Parse an HTML t-string, substitute values, and return a string of HTML.""" + return _default_processor_api.process_template(template, assume_ctx) -def svg(template: Template) -> Node: - """Parse a standalone SVG fragment and return a tree of Nodes. +def svg(template: Template, assume_ctx: ProcessContext | None = None) -> str: + """Parse a standalone SVG fragment and return a string of HTML. Use when the template does not contain an ```` wrapper element. Tag and attribute case-fixing (e.g. ``clipPath``, ``viewBox``) are applied @@ -627,6 +982,8 @@ def svg(template: Template) -> Node: When the template does contain ````, use ``html()`` — the SVG context is detected automatically. """ - cachable = CachableTemplate(template, svg_context=True) - t_node = _parse_and_cache(cachable) - return _resolve_t_node(t_node, template.interpolations) + if assume_ctx is None: + # We should probably be setting this to some sort of escaping function + # can raw text exist in SVG or MathML? + assume_ctx = make_ctx(parent_tag=DEFAULT_NORMAL_TEXT_ELEMENT, ns="svg") + return html(template, assume_ctx) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 1a3f728..f4fc5fa 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1,16 +1,33 @@ import datetime import typing as t -from collections.abc import Callable, Iterable -from dataclasses import dataclass, field -from itertools import product +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain, product from string.templatelib import Template import pytest from markupsafe import Markup +from markupsafe import escape as markupsafe_escape from .callables import get_callable_info -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .processor import _prep_component_kwargs, html +from .escaping import escape_html_text +from .processor import ( + CachedParserService, + cached_processor_service_factory, + make_ctx, + processor_service_factory, +) +from .processor import ( + _prep_component_kwargs as prep_component_kwargs, +) +from .protocols import HasHTMLDunder + +processor_api = processor_service_factory(slash_void=True, uppercase_doctype=True) + + +def html(*args, **kwargs): + return processor_api.process_template(*args, **kwargs) + # -------------------------------------------------------------------------- # Basic HTML parsing tests @@ -20,1430 +37,1687 @@ # # Text # -def test_empty(): - node = html(t"") - assert node == Fragment(children=[]) - assert str(node) == "" - - -def test_text_literal(): - node = html(t"Hello, world!") - assert node == Text("Hello, world!") - assert str(node) == "Hello, world!" - - -def test_text_singleton(): - greeting = "Hello, Alice!" - node = html(t"{greeting}") - assert node == Text("Hello, Alice!") - assert str(node) == "Hello, Alice!" - - -def test_text_template(): - name = "Alice" - node = html(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "Hello, Alice!" - +class TestBareTemplate: + def test_empty(self): + assert html(t"") == "" + + def test_text_literal(self): + assert html(t"Hello, world!") == "Hello, world!" + + def test_text_singleton(self): + greeting = "Hello, Alice!" + assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!" + assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!" + assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!" + assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!" + assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!" + + def test_text_singleton_without_parent(self): + greeting = "" + with pytest.raises(NotImplementedError): + # Explicitly set the parent tag as None. + ctx = make_ctx(parent_tag=None, ns="html") + _ = html(t"{greeting}", assume_ctx=ctx) + + def test_text_singleton_explicit_parent_script(self): + greeting = "" + res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script")) + assert res == "\\x3c/script>" + assert res != "" + + def test_text_singleton_explicit_parent_div(self): + greeting = "
    " + res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div")) + assert res == "</div>" + assert res != "
    " + + def test_text_template(self): + name = "Alice" + assert ( + html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) + == "Hello, Alice!" + ) -def test_text_template_escaping(): - name = "Alice & Bob" - node = html(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) - assert str(node) == "Hello, Alice & Bob!" + def test_text_template_escaping(self): + name = "Alice & Bob" + assert ( + html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) + == "Hello, Alice & Bob!" + ) + def test_parse_entities_are_escaped_no_parent_tag(self): + res = html(t"</p>") + assert res == "</p>", "Default to standard escaping." -# -# Comments. -# -def test_comment(): - node = html(t"") - assert node == Comment("This is a comment") - assert str(node) == "" +class LiteralHTML: + """Text is returned as is by __html__.""" -def test_comment_template(): - text = "comment" - node = html(t"") - assert node == Comment("This is a comment") - assert str(node) == "" + def __init__(self, text): + self.text = text + def __html__(self): + # In a real app, this would come from a sanitizer or trusted source + return self.text -def test_comment_template_escaping(): - text = "-->comment" - node = html(t"") - assert node == Comment("This is a -->comment") - assert str(node) == "" +def test_literal_html_has_html_dunder(): + assert isinstance(LiteralHTML, HasHTMLDunder) -# -# Document types. -# -def test_parse_document_type(): - node = html(t"") - assert node == DocumentType("html") - assert str(node) == "" +def test_markup_has_html_dunder(): + assert isinstance(Markup, HasHTMLDunder) -# -# Elements -# -def test_parse_void_element(): - node = html(t"
    ") - assert node == Element("br") - assert str(node) == "
    " - - -def test_parse_void_element_self_closed(): - node = html(t"
    ") - assert node == Element("br") - assert str(node) == "
    " - - -def test_parse_chain_of_void_elements(): - # Make sure our handling of CPython issue #69445 is reasonable. - node = html(t"



    ") - assert node == Fragment( - children=[ - Element("br"), - Element("hr"), - Element("img", attrs={"src": "image.png"}), - Element("br"), - Element("hr"), - ], - ) - assert str(node) == '



    ' +class TestComment: + def test_literal(self): + assert html(t"") == "" -def test_parse_element_with_text(): - node = html(t"

    Hello, world!

    ") - assert node == Element( - "p", - children=[ - Text("Hello, world!"), - ], - ) - assert str(node) == "

    Hello, world!

    " + # + # Singleton / Exact Match + # + def test_singleton_str(self): + text = "This is a comment" + assert html(t"") == "" + def test_singleton_object(self): + assert html(t"") == "" -def test_parse_nested_elements(): - node = html(t"

    Hello

    World

    ") - assert node == Element( - "div", - children=[ - Element("p", children=[Text("Hello")]), - Element("p", children=[Text("World")]), - ], - ) - assert str(node) == "

    Hello

    World

    " + def test_singleton_none(self): + assert html(t"") == "" + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"") == "" -def test_parse_entities_are_escaped(): - node = html(t"

    </p>

    ") - assert node == Element( - "p", - children=[Text("

    ")], + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), ) - assert str(node) == "

    </p>

    " + def test_singleton_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("-->") + assert html(t"") == "-->", ( + "DO NOT DO THIS! This is just an advanced escape hatch." + ) + def test_singleton_escaping(self): + text = "-->comment" + assert html(t"") == "" + + # + # Templated -- literal text mixed with interpolation(s) + # + def test_templated_str(self): + text = "comment" + assert html(t"") == "" + + def test_templated_object(self): + assert html(t"") == "" + + def test_templated_none(self): + assert html(t"") == "" + + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert html(t"") == "" + + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_templated_has_html_dunder_error(self, html_dunder_cls): + """Objects with __html__ are not processed with literal text or other interpolations.""" + text = html_dunder_cls("in a comment") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_templated_multiple_interpolations(self): + text = "comment" + assert ( + html(t"") + == "" + ) -# -------------------------------------------------------------------------- -# Interpolated text content -# -------------------------------------------------------------------------- + def test_templated_escaping(self): + # @TODO: There doesn't seem to be a way to properly escape this + # so we just use an entity to break the special closing string + # even though it won't be actually unescaped by anything. There + # might be something better for this. + text = "-->comment" + assert html(t"") == "" + def test_not_supported__recursive_template_error(self): + text_t = t"comment" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") -def test_interpolated_text_content(): - name = "Alice" - node = html(t"

    Hello, {name}!

    ") - assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "

    Hello, Alice!

    " + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "comment"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") -def test_escaping_of_interpolated_text_content(): - name = "" - node = html(t"

    Hello, {name}!

    ") - assert node == Element( - "p", children=[Text("Hello, "), Text(""), Text("!")] - ) - assert str(node) == "

    Hello, <Alice & Bob>!

    " +class TestDocumentType: + def test_literal(self): + assert html(t"") == "" -class Convertible: - def __str__(self): - return "string" - - def __repr__(self): - return "repr" +class TestVoidElementLiteral: + def test_void(self): + assert html(t"
    ") == "
    " + def test_void_self_closed(self): + assert html(t"
    ") == "
    " -def test_conversions(): - c = Convertible() - assert f"{c!s}" == "string" - assert f"{c!r}" == "repr" - node = html(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") - assert node == Fragment( - children=[ - Element("li", children=[Text("string")]), - Element("li", children=[Text("repr")]), - Element("li", children=[Text("'\\U0001f60a'")]), - ], - ) - - -def test_interpolated_in_content_node(): - # https://github.com/t-strings/tdom/issues/68 - evil = "") - assert node == Element( - "style", - children=[ - Text("" - ) - + def test_void_mixed_closing(self): + assert html(t"
    Is this content?
    ") == "
    Is this content?
    " -def test_interpolated_trusted_in_content_node(): - # https://github.com/t-strings/tdom/issues/68 - node = html(t"") - assert node == Element( - "script", - children=[Text("if (a < b && c > d) { alert('wow'); }")], - ) - assert str(node) == ("") + def test_chain_of_void_elements(self): + # Make sure our handling of CPython issue #69445 is reasonable. + assert ( + html(t"



    ") + == '



    ' + ) -def test_script_elements_error(): - nested_template = t"
    " - # Putting non-text content inside a script is not allowed. - with pytest.raises(TypeError): - node = html(t"") - _ = str(node) +class TestNormalTextElementLiteral: + def test_empty(self): + assert html(t"
    ") == "
    " + def test_with_text(self): + assert html(t"

    Hello, world!

    ") == "

    Hello, world!

    " -# -------------------------------------------------------------------------- -# Interpolated non-text content -# -------------------------------------------------------------------------- + def test_nested_elements(self): + assert ( + html(t"

    Hello

    World

    ") + == "

    Hello

    World

    " + ) + def test_entities_are_escaped(self): + """Literal entities interpreted by parser but escaped in output.""" + res = html(t"

    </p>

    ") + assert res == "

    </p>

    ", res -def test_interpolated_false_content(): - node = html(t"
    {False}
    ") - assert node == Element("div") - assert str(node) == "
    " +class TestNormalTextElementDynamic: + def test_singleton_None(self): + assert html(t"

    {None}

    ") == "

    " -def test_interpolated_none_content(): - node = html(t"
    {None}
    ") - assert node == Element("div", children=[]) - assert str(node) == "
    " + def test_singleton_str(self): + name = "Alice" + assert html(t"

    {name}

    ") == "

    Alice

    " + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"

    {bool_value}

    ") == "

    " -def test_interpolated_zero_arg_function(): - def get_value(): - return "dynamic" + def test_singleton_object(self): + assert html(t"

    {0}

    ") == "

    0

    " - node = html(t"

    The value is {get_value:callback}.

    ") - assert node == Element( - "p", children=[Text("The value is "), Text("dynamic"), Text(".")] + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), ) + def test_singleton_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("Alright!") + assert html(t"

    {content}

    ") == "

    Alright!

    " + def test_singleton_simple_template(self): + name = "Alice" + text_t = t"Hi {name}" + assert html(t"

    {text_t}

    ") == "

    Hi Alice

    " -def test_interpolated_multi_arg_function_fails(): - def add(a, b): # pragma: no cover - return a + b - - with pytest.raises(TypeError): - _ = html(t"

    The sum is {add:callback}.

    ") - - -# -------------------------------------------------------------------------- -# Raw HTML injection tests -# -------------------------------------------------------------------------- - + def test_singleton_simple_iterable(self): + strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] + assert html(t"

    {strs}

    ") == "

    Strings...Yeah!Rock...Yeah!

    " -def test_raw_html_injection_with_markupsafe(): - raw_content = Markup("I am bold") - node = html(t"
    {raw_content}
    ") - assert node == Element("div", children=[Text(text=raw_content)]) - assert str(node) == "
    I am bold
    " + def test_singleton_escaping(self): + text = '''<>&'"''' + assert html(t"

    {text}

    ") == "

    <>&'"

    " + def test_templated_None(self): + assert html(t"

    Response: {None}.

    ") == "

    Response: .

    " -def test_raw_html_injection_with_dunder_html_protocol(): - class SafeContent: - def __init__(self, text): - self._text = text - - def __html__(self): - # In a real app, this would come from a sanitizer or trusted source - return f"{self._text}" - - content = SafeContent("emphasized") - node = html(t"

    Here is some {content}.

    ") - assert node == Element( - "p", - children=[ - Text("Here is some "), - Text(Markup("emphasized")), - Text("."), - ], - ) - assert str(node) == "

    Here is some emphasized.

    " - - -def test_raw_html_injection_with_format_spec(): - raw_content = "underlined" - node = html(t"

    This is {raw_content:safe} text.

    ") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(Markup(raw_content)), - Text(" text."), - ], - ) - assert str(node) == "

    This is underlined text.

    " - - -def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): - supposedly_safe = Markup("italic") - node = html(t"

    This is {supposedly_safe:unsafe} text.

    ") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(str(supposedly_safe)), - Text(" text."), - ], - ) - assert str(node) == "

    This is <i>italic</i> text.

    " - - -# -------------------------------------------------------------------------- -# Conditional rendering and control flow -# -------------------------------------------------------------------------- + def test_templated_str(self): + name = "Alice" + assert html(t"

    Response: {name}.

    ") == "

    Response: Alice.

    " + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert html(t"

    Response: {bool_value}

    ") == "

    Response:

    " -def test_conditional_rendering_with_if_else(): - is_logged_in = True - user_profile = t"Welcome, User!" - login_prompt = t"Please log in" - node = html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + def test_templated_object(self): + assert html(t"

    Response: {0}.

    ") == "

    Response: 0.

    " - assert node == Element( - "div", children=[Element("span", children=[Text("Welcome, User!")])] + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), ) - assert str(node) == "
    Welcome, User!
    " - - is_logged_in = False - node = html(t"
    {user_profile if is_logged_in else login_prompt}
    ") - assert str(node) == '' - - -def test_conditional_rendering_with_and(): - show_warning = True - warning_message = t'
    Warning!
    ' - node = html(t"
    {show_warning and warning_message}
    ") + def test_templated_has_html_dunder(self, html_dunder_cls): + text = html_dunder_cls("Alright!") + assert ( + html(t"

    Response: {text}.

    ") == "

    Response: Alright!.

    " + ) - assert node == Element( - "main", - children=[ - Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]), - ], - ) - assert str(node) == '
    Warning!
    ' + def test_templated_simple_template(self): + name = "Alice" + text_t = t"Hi {name}" + assert html(t"

    Response: {text_t}.

    ") == "

    Response: Hi Alice.

    " - show_warning = False - node = html(t"
    {show_warning and warning_message}
    ") - # Assuming False renders nothing - assert str(node) == "
    " + def test_templated_simple_iterable(self): + strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] + assert ( + html(t"

    Response: {strs}.

    ") + == "

    Response: Strings...Yeah!Rock...Yeah!.

    " + ) + def test_templated_escaping(self): + text = '''<>&'"''' + assert ( + html(t"

    Response: {text}.

    ") + == "

    Response: <>&'".

    " + ) -# -------------------------------------------------------------------------- -# Interpolated nesting of templates and elements -# -------------------------------------------------------------------------- + def test_templated_escaping_in_literals(self): + text = "This text is fine" + assert ( + html(t"

    The literal has < in it: {text}.

    ") + == "

    The literal has < in it: This text is fine.

    " + ) + def test_iterable_of_templates(self): + items = ["Apple", "Banana", "Cherry"] + assert ( + html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") + == "
    • Apple
    • Banana
    • Cherry
    " + ) -def test_interpolated_template_content(): - child = t"Child" - node = html(t"
    {child}
    ") - assert node == Element("div", children=[html(child)]) - assert str(node) == "
    Child
    " + def test_iterable_of_templates_of_iterable_of_templates(self): + outer = ["fruit", "more fruit"] + inner = ["apple", "banana", "cherry"] + inner_items = [t"
  • {item}
  • " for item in inner] + outer_items = [ + t"
  • {category}
      {inner_items}
  • " for category in outer + ] + assert ( + html(t"
      {outer_items}
    ") + == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " + ) -def test_interpolated_element_content(): - child = html(t"Child") - node = html(t"
    {child}
    ") - assert node == Element("div", children=[child]) - assert str(node) == "
    Child
    " +class TestRawTextElementLiteral: + def test_script_empty(self): + assert html(t"") == "" + def test_style_empty(self): + assert html(t"") == "" -def test_interpolated_nonstring_content(): - number = 42 - node = html(t"

    The answer is {number}.

    ") - assert node == Element( - "p", children=[Text("The answer is "), Text("42"), Text(".")] - ) - assert str(node) == "

    The answer is 42.

    " - - -def test_list_items(): - items = ["Apple", "Banana", "Cherry"] - node = html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") - assert node == Element( - "ul", - children=[ - Element("li", children=[Text("Apple")]), - Element("li", children=[Text("Banana")]), - Element("li", children=[Text("Cherry")]), - ], - ) - assert str(node) == "
    • Apple
    • Banana
    • Cherry
    " - - -def test_nested_list_items(): - # TODO XXX this is a pretty abusrd test case; clean it up when refactoring - outer = ["fruit", "more fruit"] - inner = ["apple", "banana", "cherry"] - inner_items = [t"
  • {item}
  • " for item in inner] - outer_items = [t"
  • {category}
      {inner_items}
  • " for category in outer] - node = html(t"
      {outer_items}
    ") - assert node == Element( - "ul", - children=[ - Element( - "li", - children=[ - Text("fruit"), - Element( - "ul", - children=[ - Element("li", children=[Text("apple")]), - Element("li", children=[Text("banana")]), - Element("li", children=[Text("cherry")]), - ], - ), - ], - ), - Element( - "li", - children=[ - Text("more fruit"), - Element( - "ul", - children=[ - Element("li", children=[Text("apple")]), - Element("li", children=[Text("banana")]), - Element("li", children=[Text("cherry")]), - ], - ), - ], - ), - ], - ) - assert ( - str(node) - == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " - ) + def test_script_with_content(self): + assert html(t"") == "" + def test_style_with_content(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + html(t"") + == "" + ) -# -------------------------------------------------------------------------- -# Attributes -# -------------------------------------------------------------------------- + def test_script_with_content_escaped_in_normal_text(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + html(t"") + == "" + ), "The < should not be escaped." + + def test_style_with_content_escaped_in_normal_text(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + html(t"") + == "" + ), "The > should not be escaped." + + def test_not_supported_recursive_template_error(self): + text_t = t"comment" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "comment"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + +class TestEscapableRawTextElementLiteral: + def test_title_empty(self): + assert html(t"") == "" + + def test_textarea_empty(self): + assert html(t"") == "" + + def test_title_with_content(self): + assert html(t"Content") == "Content" + + def test_textarea_with_content(self): + assert html(t"") == "" + + def test_title_with_escapable_content(self): + assert ( + html(t"Are t-strings > everything?") + == "Are t-strings > everything?" + ), "The > can be escaped in this content type." + + def test_textarea_with_escapable_content(self): + assert ( + html(t"") + == "" + ), "The p tags can be escaped in this content type." + + +class TestRawTextScriptDynamic: + def test_singleton_none(self): + assert html(t"") == "" + + def test_singleton_str(self): + content = "var x = 1;" + assert html(t"") == "" + + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"") == "" + + def test_singleton_object(self): + content = 0 + assert html(t"") == "" + + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): + # @TODO: We should probably put some double override to prevent this by accident. + # Or just disable this and if people want to do this then put the + # content in a SCRIPT and inject the whole thing with a __html__? + content = html_dunder_cls("") + assert html(t"") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" + ) + def test_singleton_escaping(self): + content = "" + script_t = t"" + bad_output = script_t.strings[0] + content + script_t.strings[1] + assert html(script_t) == "" + assert html(script_t) != bad_output, "Sanity check." + + def test_templated_none(self): + assert ( + html(t"") + == "" + ) -def test_literal_attrs(): - node = html( - t"" - ) - assert node == Element( - "a", - attrs={ - "id": "example_link", - "autofocus": None, - "title": "", - "href": "https://example.com", - "target": "_blank", - }, - ) - assert ( - str(node) - == '' - ) + def test_templated_str(self): + content = "var x = 1" + assert ( + html(t"") + == "" + ) + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert ( + html(t"") + == "" + ) -def test_literal_attr_escaped(): - node = html(t'') - assert node == Element( - "a", - attrs={"title": "<"}, - ) - assert str(node) == '' + def test_templated_object(self): + content = 0 + assert ( + html(t"") + == "" + ) + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_templated_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("anything") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_templated_escaping(self): + content = "" + script_t = t"" + bad_output = script_t.strings[0] + content + script_t.strings[1] + assert html(script_t) == "" + assert html(script_t) != bad_output, "Sanity check." + + def test_templated_multiple_interpolations(self): + assert ( + html(t"") + == "" + ) -def test_interpolated_attr(): - url = "https://example.com/" - node = html(t'') - assert node == Element("a", attrs={"href": "https://example.com/"}) - assert str(node) == '' + def test_not_supported_recursive_template_error(self): + text_t = t"script" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "script"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") -def test_interpolated_attr_escaped(): - url = 'https://example.com/?q="test"&lang=en' - node = html(t'') - assert node == Element( - "a", - attrs={"href": 'https://example.com/?q="test"&lang=en'}, - ) - assert ( - str(node) == '' - ) +class TestRawTextStyleDynamic: + def test_singleton_none(self): + assert html(t"") == "" -def test_interpolated_attr_unquoted(): - id = "roquefort" - node = html(t"
    ") - assert node == Element("div", attrs={"id": "roquefort"}) - assert str(node) == '
    ' + def test_singleton_str(self): + content = "div { background-color: red; }" + assert ( + html(t"") + == "" + ) + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"") == "" + + def test_singleton_object(self): + content = 0 + assert html(t"") == "" + + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): + # @TODO: We should probably put some double override to prevent this by accident. + # Or just disable this and if people want to do this then put the + # content in a STYLE and inject the whole thing with a __html__? + content = html_dunder_cls("") + assert html(t"") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch!" + ) -def test_interpolated_attr_true(): - disabled = True - node = html(t"") - assert node == Element("button", attrs={"disabled": None}) - assert str(node) == "" + def test_singleton_escaping(self): + content = "" + style_t = t"" + bad_output = style_t.strings[0] + content + style_t.strings[1] + assert html(style_t) == "" + assert html(style_t) != bad_output, "Sanity check." + + def test_templated_none(self): + assert ( + html(t"") + == "" + ) + def test_templated_str(self): + content = " h2 { background-color: blue; }" + assert ( + html(t"") + == "" + ) -def test_interpolated_attr_false(): - disabled = False - node = html(t"") - assert node == Element("button") - assert str(node) == "" + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert ( + html(t"") + == "" + ) + def test_templated_object(self): + padding_right = 0 + assert ( + html(t"") + == "" + ) -def test_interpolated_attr_none(): - disabled = None - node = html(t"") - assert node == Element("button") - assert str(node) == "" + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_templated_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("anything") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_templated_escaping(self): + content = "" + style_t = t"" + bad_output = style_t.strings[0] + content + style_t.strings[1] + assert ( + html(style_t) == "" + ) + assert html(style_t) != bad_output, "Sanity check." + def test_templated_multiple_interpolations(self): + assert ( + html( + t"" + ) + == "" + ) -def test_interpolate_attr_empty_string(): - node = html(t'
    ') - assert node == Element( - "div", - attrs={"title": ""}, - ) - assert str(node) == '
    ' + def test_not_supported_recursive_template_error(self): + text_t = t"style" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "style"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + +class TestEscapableRawTextTitleDynamic: + def test_singleton_none(self): + assert html(t"{None}") == "" + + def test_singleton_str(self): + content = "Welcome To TDOM" + assert html(t"{content}") == "Welcome To TDOM" + + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"{bool_value}") == "" + + def test_singleton_object(self): + content = 0 + assert html(t"{content}") == "0" + + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): + # @TODO: We should probably put some double override to prevent this by accident. + content = html_dunder_cls("") + assert html(t"{content}") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch!" + ) + def test_singleton_escaping(self): + content = "" + assert html(t"{content}") == "</title>" -def test_spread_attr(): - attrs = {"href": "https://example.com/", "target": "_blank"} - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank"}, - ) - assert str(node) == '' + def test_templated_none(self): + assert ( + html(t"A great story about: {None}") + == "A great story about: " + ) + def test_templated_str(self): + content = "TDOM" + assert ( + html(t"A great story about: {content}") + == "A great story about: TDOM" + ) -def test_spread_attr_none(): - attrs = None - node = html(t"") - assert node == Element("a") - assert str(node) == "" + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert ( + html(t"A great story; {bool_value}") + == "A great story; " + ) + def test_templated_object(self): + content = 0 + assert ( + html(t"A great number: {content}") + == "A great number: 0" + ) -def test_spread_attr_type_errors(): - for attrs in (0, [], (), False, True): - with pytest.raises(TypeError): - _ = html(t"") - - -def test_templated_attr_mixed_interpolations_start_end_and_nest(): - left, middle, right = 1, 3, 5 - prefix, suffix = t'
    ' - # Check interpolations at start, middle and/or end of templated attr - # or a combination of those to make sure text is not getting dropped. - for left_part, middle_part, right_part in product( - (t"{left}", Template(str(left))), - (t"{middle}", Template(str(middle))), - (t"{right}", Template(str(right))), - ): - test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix - node = html(test_t) - assert node == Element( - "div", - attrs={"data-range": "1-3-5"}, - ) - assert str(node) == '
    ' - - -def test_templated_attr_no_quotes(): - start = 1 - end = 5 - node = html(t"
    ") - assert node == Element( - "div", - attrs={"data-range": "1-5"}, - ) - assert str(node) == '
    ' + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_templated_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("No") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"Literal html?: {content}") + + def test_templated_escaping(self): + content = "" + assert ( + html(t"The end tag: {content}.") + == "The end tag: </title>." + ) + def test_templated_multiple_interpolations(self): + assert ( + html(t"The number {0} is less than {1}.") + == "The number 0 is less than 1." + ) -def test_attr_merge_disjoint_interpolated_attr_spread_attr(): - attrs = {"href": "https://example.com/", "id": "link1"} - target = "_blank" - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, - ) - assert str(node) == '' + def test_not_supported_recursive_template_error(self): + text_t = t"title" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"{text_t}") + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "title"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"{texts}") -def test_attr_merge_overlapping_spread_attrs(): - attrs1 = {"href": "https://example.com/", "id": "overwrtten"} - attrs2 = {"target": "_blank", "id": "link1"} - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, - ) - assert str(node) == '' +class TestEscapableRawTextTextareaDynamic: + def test_singleton_none(self): + assert html(t"") == "" -def test_attr_merge_replace_literal_attr_str_str(): - attrs = {"title": "fresh"} - node = html(t'
    ') - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' + def test_singleton_str(self): + content = "Welcome To TDOM" + assert ( + html(t"") + == "" + ) + @pytest.mark.parametrize("bool_value", (True, False)) + def test_singleton_bool(self, bool_value): + assert html(t"") == "" + + def test_singleton_object(self): + content = 0 + assert html(t"") == "" + + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): + # @TODO: We should probably put some double override to prevent this by accident. + content = html_dunder_cls("") + assert ( + html(t"") + == "" + ), "DO NOT DO THIS! This is just an advanced escape hatch!" + + def test_singleton_escaping(self): + content = "" + assert ( + html(t"") + == "" + ) -def test_attr_merge_replace_literal_attr_str_true(): - attrs = {"title": True} - node = html(t'
    ') - assert node == Element("div", {"title": None}) - assert str(node) == "
    " + def test_templated_none(self): + assert ( + html(t"") + == "" + ) + def test_templated_str(self): + content = "TDOM" + assert ( + html(t"") + == "" + ) -def test_attr_merge_replace_literal_attr_true_str(): - attrs = {"title": "fresh"} - node = html(t"
    ") - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' + @pytest.mark.parametrize("bool_value", (True, False)) + def test_templated_bool(self, bool_value): + assert ( + html(t"") + == "" + ) + def test_templated_object(self): + content = 0 + assert ( + html(t"") + == "" + ) -def test_attr_merge_remove_literal_attr_str_none(): - attrs = {"title": None} - node = html(t'
    ') - assert node == Element("div") - assert str(node) == "
    " + @pytest.mark.parametrize( + "html_dunder_cls", + ( + LiteralHTML, + Markup, + ), + ) + def test_templated_has_html_dunder(self, html_dunder_cls): + content = html_dunder_cls("No") + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + + def test_templated_multiple_interpolations(self): + assert ( + html(t"") + == "" + ) + def test_templated_escaping(self): + content = "" + assert ( + html(t"") + == "" + ) -def test_attr_merge_remove_literal_attr_true_none(): - attrs = {"title": None} - node = html(t"
    ") - assert node == Element("div") - assert str(node) == "
    " + def test_not_supported_recursive_template_error(self): + text_t = t"textarea" + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "textarea"] + with pytest.raises(ValueError, match="not supported"): + _ = html(t"") -def test_attr_merge_other_literal_attr_intact(): - attrs = {"alt": "fresh"} - node = html(t'') - assert node == Element("img", {"title": "default", "alt": "fresh"}) - assert str(node) == 'fresh' +class Convertible: + def __str__(self): + return "string" -# -# Special data attribute handling. -# -def test_interpolated_data_attributes(): - data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - node = html(t"
    User Info
    ") - assert node == Element( - "div", - attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None}, - children=[Text("User Info")], - ) - assert ( - str(node) - == '
    User Info
    ' - ) + def __repr__(self): + return "repr" -def test_data_attr_toggle_to_str(): - data_dict = {"selected": "yes"} - for node in [ - html(t"
    "), - html(t'
    '), - ]: - assert node == Element("div", {"data-selected": "yes"}) - assert str(node) == '
    ' +def test_convertible_fixture(): + """Make sure test fixture is working correctly.""" + c = Convertible() + assert f"{c!s}" == "string" + assert f"{c!r}" == "repr" -def test_data_attr_toggle_to_true(): - data_dict = {"selected": True} - node = html(t'
    ') - assert node == Element("div", {"data-selected": None}) - assert str(node) == "
    " +def wrap_template_in_tags( + start_tag: str, template: Template, end_tag: str | None = None +): + """Utility for testing templated text but with different containing tags.""" + if end_tag is None: + end_tag = start_tag + return Template(f"<{start_tag}>") + template + Template(f"") -def test_data_attr_unrelated_unaffected(): - data_dict = {"active": True} - node = html(t"
    ") - assert node == Element("div", {"data-selected": None, "data-active": None}) - assert str(node) == "
    " +def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None): + """Utility for testing expected text but with different containing tags.""" + if end_tag is None: + end_tag = start_tag + # Stringify to flatten `Markup()` + content = str(content) + return f"<{start_tag}>" + content + f"" -def test_data_attr_templated_error(): - data1 = {"user-id": "user-123"} - data2 = {"role": "admin"} - with pytest.raises(TypeError): - node = html(t'
    ') - print(str(node)) +class TestInterpolationConversion: + def test_str(self): + c = Convertible() + for tag in ("p", "script", "title"): + assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags( + tag, "string" + ) + def test_repr(self): + c = Convertible() + for tag in ("p", "script", "title"): + assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags( + tag, "repr" + ) -def test_data_attr_none(): - button_data = None - node = html(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" + def test_ascii_raw_text(self): + # single quotes are not escaped in raw text + assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags( + "script", ascii("😊") + ) + def test_ascii_escapable_normal_and_raw(self): + # single quotes are escaped + for tag in ("p", "title"): + assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags( + tag, escape_html_text(ascii("😊")) + ) -def test_data_attr_errors(): - for v in [False, [], (), 0, "data?"]: - with pytest.raises(TypeError): - _ = html(t"") +class TestInterpolationFormatSpec: + def test_normal_text_safe(self): + raw_content = "underlined" + assert ( + html(t"

    This is {raw_content:safe} text.

    ") + == "

    This is underlined text.

    " + ) -def test_data_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') - assert node == Element( - "p", - attrs={"data": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger data expansion." + def test_raw_text_safe(self): + # @TODO: What should even happen here? + raw_content = "" + assert ( + html(t"") == "" + ), "DO NOT DO THIS! This is an advanced escape hatch." + + def test_escapable_raw_text_safe(self): + raw_content = "underlined" + assert ( + html(t"") + == "" + ) + def test_normal_text_unsafe(self): + supposedly_safe = Markup("italic") + assert ( + html(t"

    This is {supposedly_safe:unsafe} text.

    ") + == "

    This is <i>italic</i> text.

    " + ) -# -# Special aria attribute handling. -# -def test_aria_templated_attr_error(): - aria1 = {"label": "close"} - aria2 = {"hidden": "true"} - with pytest.raises(TypeError): - node = html(t'
    ') - print(str(node)) - - -def test_aria_interpolated_attr_dict(): - aria = {"label": "Close", "hidden": True, "another": False, "more": None} - node = html(t"") - assert node == Element( - "button", - attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, - children=[Text("X")], - ) - assert ( - str(node) - == '' - ) + def test_raw_text_unsafe(self): + # @TODO: What should even happen here? + supposedly_safe = "" + assert ( + html(t"") + == "" + ) + assert ( + html(t"") + != "" + ) # Sanity check + + def test_escapable_raw_text_unsafe(self): + supposedly_safe = Markup("italic") + assert ( + html(t"") + == "" + ) + def test_all_text_callback(self): + def get_value(): + return "dynamic" + + for tag in ("p", "script", "style"): + assert ( + html( + Template(f"<{tag}>") + + t"The value is {get_value:callback}." + + Template(f"") + ) + == f"<{tag}>The value is dynamic." + ) -def test_aria_interpolate_attr_none(): - button_aria = None - node = html(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" + def test_callback_nonzero_callable_error(self): + def add(a, b): + return a + b + assert add(1, 2) == 3, "Make sure fixture could work..." -def test_aria_attr_errors(): - for v in [False, [], (), 0, "aria?"]: with pytest.raises(TypeError): - _ = html(t"") - - -def test_aria_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') - assert node == Element( - "p", - attrs={"aria": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger aria expansion." - - -# -# Special class attribute handling. -# -def test_interpolated_class_attribute(): - 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": "red btn btn-primary one two active blue green yellow"}, - children=[Text("Click me")], - ) - assert ( - str(node) - == '' - ) - - -def test_interpolated_class_attribute_with_multiple_placeholders(): - classes1 = ["btn", "btn-primary"] - classes2 = [False, None, {"active": True}] - node = html(t'') - # CONSIDER: Is this what we want? Currently, when we have multiple - # placeholders in a single attribute, we treat it as a string attribute. - assert node == Element( - "button", - attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"}, - children=[Text("Click me")], - ) - - -def test_interpolated_attribute_spread_with_class_attribute(): - attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - node = html(t"") - assert node == Element( - "button", - attrs={"id": "button1", "class": "btn btn-primary"}, - children=[Text("Click me")], - ) - assert str(node) == '' - - -def test_class_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') - assert node == Element( - "p", - attrs={"class": "red red", "id": "veryred"}, - ), "A single literal attribute should not trigger class accumulator." + for tag in ("p", "script", "style"): + _ = html( + Template(f"<{tag}>") + + t"The sum is {add:callback}." + + Template(f"") + ) -def test_class_none_ignored(): - class_item = None - node = html(t"

    ") - assert node == Element("p") - # Also ignored inside a sequence. - node = html(t"

    ") - assert node == Element("p") - - -def test_class_type_errors(): - for class_item in (False, True, 0): - with pytest.raises(TypeError): - _ = html(t"

    ") - with pytest.raises(TypeError): - _ = html(t"

    ") - +# -------------------------------------------------------------------------- +# Conditional rendering and control flow +# -------------------------------------------------------------------------- -def test_class_merge_literals(): - node = html(t'

    ') - assert node == Element("p", {"class": "red blue"}) +class TestUsagePatterns: + def test_conditional_rendering_with_if_else(self): + is_logged_in = True + user_profile = t"Welcome, User!" + login_prompt = t"Please log in" + assert ( + html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == "
    Welcome, User!
    " + ) -def test_class_merge_literal_then_interpolation(): - class_item = "blue" - node = html(t'

    ') - assert node == Element("p", {"class": "red blue"}) + is_logged_in = False + assert ( + html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == '' + ) -# -# Special style attribute handling. -# -def test_style_literal_attr_passthru(): - 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!

    ' +# -------------------------------------------------------------------------- +# Attributes +# -------------------------------------------------------------------------- +class TestLiteralAttribute: + """Test literal (non-dynamic) attributes.""" + + def test_literal_attrs(self): + assert ( + html( + t"" + ) + == '' + ) + def test_literal_attr_escaped(self): + assert ( + html(t'') + == '' + ) -def test_style_in_interpolated_attr(): - styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - node = html(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold; font-size: 16px"}, - children=[Text("Warning!")], - ) - assert ( - str(node) - == '

    Warning!

    ' - ) +class TestInterpolatedAttribute: + """Test interpolated attributes, entire value is an exact interpolation.""" -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_interpolated_attr(self): + url = "https://example.com/" + assert html(t'') == '' + def test_interpolated_attr_escaped(self): + url = 'https://example.com/?q="test"&lang=en' + assert ( + html(t'') + == '' + ) -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_interpolated_attr_unquoted(self): + id = "roquefort" + assert html(t"
    ") == '
    ' + def test_interpolated_attr_true(self): + disabled = True + assert ( + html(t"") + == "" + ) -def test_style_merged_from_all_attrs(): - attrs = {"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_interpolated_attr_false(self): + disabled = False + assert html(t"") == "" + def test_interpolated_attr_none(self): + disabled = None + assert html(t"") == "" -def test_style_override_left_to_right(): - suffix = t">

    " - cb_dict = {"color": "blue"} - scy_dict = {"style": {"color": "yellow"}} - parts = [ - (t'

    ' - - -def test_interpolated_style_attribute_multiple_placeholders(): - styles1 = {"color": "red"} - styles2 = {"font-weight": "bold"} - # CONSIDER: Is this what we want? Currently, when we have multiple - # 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_interpolate_attr_empty_string(self): + assert html(t'
    ') == '
    ' -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"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' +class TestSpreadAttribute: + """Test spread attributes.""" + def test_spread_attr(self): + attrs = {"href": "https://example.com/", "target": "_blank"} + assert ( + html(t"") + == '' + ) -def test_style_attribute_str(): - styles = "color: red; 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_spread_attr_none(self): + attrs = None + assert html(t"") == "" + + def test_spread_attr_type_errors(self): + for attrs in (0, [], (), False, True): + with pytest.raises(TypeError): + _ = html(t"") + + +class TestTemplatedAttribute: + def test_templated_attr_mixed_interpolations_start_end_and_nest(self): + left, middle, right = 1, 3, 5 + prefix, suffix = t'
    ' + # Check interpolations at start, middle and/or end of templated attr + # or a combination of those to make sure text is not getting dropped. + for left_part, middle_part, right_part in product( + (t"{left}", Template(str(left))), + (t"{middle}", Template(str(middle))), + (t"{right}", Template(str(right))), + ): + test_t = ( + prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix + ) + assert html(test_t) == '
    ' + + def test_templated_attr_no_quotes(self): + start = 1 + end = 5 + assert ( + html(t"
    ") + == '
    ' + ) -def test_style_attribute_non_str_non_dict(): - with pytest.raises(TypeError): - styles = [1, 2] - _ = html(t"

    Warning!

    ") +class TestAttributeMerging: + def test_attr_merge_disjoint_interpolated_attr_spread_attr(self): + attrs = {"href": "https://example.com/", "id": "link1"} + target = "_blank" + assert ( + html(t"") + == '' + ) + def test_attr_merge_overlapping_spread_attrs(self): + attrs1 = {"href": "https://example.com/", "id": "overwrtten"} + attrs2 = {"target": "_blank", "id": "link1"} + assert ( + html(t"") + == '' + ) -def test_style_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') - assert node == Element( - "p", - attrs={"style": "invalid;invalid:", "id": "resolved"}, - ), "A single literal attribute should bypass style accumulator." + def test_attr_merge_replace_literal_attr_str_str(self): + assert ( + html(t'
    ') + == '
    ' + ) + def test_attr_merge_replace_literal_attr_str_true(self): + assert ( + html(t'
    ') + == "
    " + ) -def test_style_none(): - styles = None - node = html(t"

    ") - assert node == Element("p") + def test_attr_merge_replace_literal_attr_true_str(self): + assert ( + html(t"
    ") + == '
    ' + ) + def test_attr_merge_remove_literal_attr_str_none(self): + assert html(t'
    ') == "
    " -# -------------------------------------------------------------------------- -# Function component interpolation tests -# -------------------------------------------------------------------------- + def test_attr_merge_remove_literal_attr_true_none(self): + assert html(t"
    ") == "
    " + def test_attr_merge_other_literal_attr_intact(self): + assert ( + html(t'') + == 'fresh' + ) -def FunctionComponent( - children: Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any -) -> Template: - # Ensure type correctness of props at runtime for testing purposes - assert isinstance(first, str) - assert isinstance(second, int) - assert isinstance(third_arg, str) - new_attrs = { - "id": third_arg, - "data": {"first": first, "second": second}, - **attrs, - } - return t"
    Component: {children}
    " +class TestSpecialDataAttribute: + """Special data attribute handling.""" + + def test_interpolated_data_attributes(self): + data = { + "user-id": 123, + "role": "admin", + "wild": True, + "false": False, + "none": None, + } + assert ( + html(t"
    User Info
    ") + == '
    User Info
    ' + ) -def test_interpolated_template_component(): - node = html( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - "class": "my-comp", - }, - children=[Text("Component: "), Text("Hello, Component!")], - ) - assert ( - str(node) - == '
    Component: Hello, Component!
    ' - ) + def test_data_attr_toggle_to_str(self): + for res in [ + html(t"
    "), + html(t'
    '), + ]: + assert res == '
    ' + def test_data_attr_toggle_to_true(self): + res = html(t'
    ') + assert res == "
    " -def test_interpolated_template_component_no_children_provided(): - """Same test, but the caller didn't provide any children.""" - node = html( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - "class": "my-comp", - }, - children=[ - Text("Component: "), - ], - ) - assert ( - str(node) - == '
    Component:
    ' - ) + def test_data_attr_unrelated_unaffected(self): + res = html(t"
    ") + assert res == "
    " + def test_data_attr_templated_error(self): + data1 = {"user-id": "user-123"} + data2 = {"role": "admin"} + with pytest.raises(TypeError): + _ = html(t'
    ') + + def test_data_attr_none(self): + button_data = None + res = html(t"") + assert res == "" + + def test_data_attr_errors(self): + for v in [False, [], (), 0, "data?"]: + with pytest.raises(TypeError): + _ = html(t"") + + def test_data_literal_attr_bypass(self): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger data expansion." + ) -def test_invalid_component_invocation(): - with pytest.raises(TypeError): - _ = html(t"<{FunctionComponent}>Missing props") +class TestSpecialAriaAttribute: + """Special aria attribute handling.""" -def test_prep_component_kwargs_named(): - def InputElement(size=10, type="text"): - pass + def test_aria_templated_attr_error(self): + aria1 = {"label": "close"} + aria2 = {"hidden": "true"} + with pytest.raises(TypeError): + _ = html(t'
    ') + + def test_aria_interpolated_attr_dict(self): + aria = {"label": "Close", "hidden": True, "another": False, "more": None} + res = html(t"") + assert ( + res + == '' + ) - callable_info = get_callable_info(InputElement) - assert _prep_component_kwargs(callable_info, {"size": 20}, system_kwargs={}) == { - "size": 20 - } - assert _prep_component_kwargs( - callable_info, {"type": "email"}, system_kwargs={} - ) == {"type": "email"} - assert _prep_component_kwargs(callable_info, {}, system_kwargs={}) == {} - - -def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template: - # Ensure type correctness of props at runtime for testing purposes - assert isinstance(first, str) - assert isinstance(second, int) - assert isinstance(third_arg, str) - new_attrs = { - "id": third_arg, - "data": {"first": first, "second": second}, - } - return t"
    Component: ignore children
    " + def test_aria_interpolate_attr_none(self): + button_aria = None + res = html(t"") + assert res == "" + + def test_aria_attr_errors(self): + for v in [False, [], (), 0, "aria?"]: + with pytest.raises(TypeError): + _ = html(t"") + + def test_aria_literal_attr_bypass(self): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger aria expansion." + ) -def test_interpolated_template_component_ignore_children(): - node = html( - t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - }, - children=[Text(text="Component: ignore children")], - ) - assert ( - str(node) - == '
    Component: ignore children
    ' - ) +class TestSpecialClassAttribute: + """Special class attribute handling.""" + + def test_interpolated_class_attribute(self): + 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"" + ) + res = html(button_t) + assert ( + res + == '' + ) + def test_interpolated_class_attribute_with_multiple_placeholders(self): + classes1 = ["btn", "btn-primary"] + classes2 = [None, {"active": True}] + res = html(t'') + # CONSIDER: Is this what we want? Currently, when we have multiple + # placeholders in a single attribute, we treat it as a string attribute. + assert ( + res + == f'' + ), ( + "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified." + ) -def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: - # Ensure type correctness of props at runtime for testing purposes - assert isinstance(first, str) - assert "children" in attrs - _ = attrs.pop("children") - new_attrs = {"data-first": first, **attrs} - return t"
    Component with kwargs
    " + def test_interpolated_attribute_spread_with_class_attribute(self): + attrs = {"id": "button1", "class": ["btn", "btn-primary"]} + res = html(t"") + assert res == '' + def test_class_literal_attr_bypass(self): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger class accumulator." + ) -def test_children_always_passed_via_kwargs(): - node = html( - t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' - ) - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '
    Component with kwargs
    ' - ) + def test_class_none_ignored(self): + class_item = None + res = html(t"

    ") + assert res == "

    " + # Also ignored inside a sequence. + res = html(t"

    ") + assert res == "

    " + + def test_class_type_errors(self): + for class_item in (False, True, 0): + with pytest.raises(TypeError): + _ = html(t"

    ") + with pytest.raises(TypeError): + _ = html(t"

    ") + + def test_class_merge_literals(self): + res = html(t'

    ') + assert res == '

    ' + + def test_class_merge_literal_then_interpolation(self): + class_item = "blue" + res = html(t'

    ') + assert res == '

    ' + + +class TestSpecialStyleAttribute: + """Special style attribute handling.""" + + def test_style_literal_attr_passthru(self): + p_id = "para1" # non-literal attribute to cause attr resolution + res = html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' + + def test_style_in_interpolated_attr(self): + styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} + res = html(t"

    Warning!

    ") + assert ( + res + == '

    Warning!

    ' + ) + def test_style_in_templated_attr(self): + color = "red" + res = html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' + + def test_style_in_spread_attr(self): + attrs = {"style": {"color": "red"}} + res = html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_merged_from_all_attrs(self): + attrs = {"style": "font-size: 15px"} + style = {"font-weight": "bold"} + color = "red" + res = html( + t'

    ' + ) + assert ( + res + == '

    ' + ) -def test_children_always_passed_via_kwargs_even_when_empty(): - node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '
    Component with kwargs
    ' - ) + def test_style_override_left_to_right(self): + suffix = t">

    " + parts = [ + (t'

    ' + + def test_interpolated_style_attribute_multiple_placeholders(self): + styles1 = {"color": "red"} + styles2 = {"font-weight": "bold"} + # CONSIDER: Is this what we want? Currently, when we have multiple + # 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(self): + styles1 = {"color": "red"} + styles2 = {"font-weight": "bold"} + res = html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_interpolated_style_attribute_merged_override(self): + styles1 = {"color": "red", "font-weight": "normal"} + styles2 = {"font-weight": "bold"} + res = html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_attribute_str(self): + styles = "color: red; font-weight: bold;" + res = html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_attribute_non_str_non_dict(self): + with pytest.raises(TypeError): + styles = [1, 2] + _ = html(t"

    Warning!

    ") + + def test_style_literal_attr_bypass(self): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should bypass style accumulator." + ) + def test_style_none(self): + styles = None + res = html(t"

    ") + assert res == "

    " + + +class TestPrepComponentKwargs: + def test_named(self): + def InputElement(size=10, type="text"): + pass + + callable_info = get_callable_info(InputElement) + assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == { + "size": 20 + } + assert prep_component_kwargs( + callable_info, {"type": "email"}, children=t"" + ) == {"type": "email"} + assert prep_component_kwargs(callable_info, {}, children=t"") == {} + + @pytest.mark.skip("Should we just ignore unused user-specified kwargs?") + def test_unused_kwargs(self): + def InputElement(size=10, type="text"): + pass + + callable_info = get_callable_info(InputElement) + with pytest.raises(ValueError): + assert ( + prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {} + ) -def ColumnsComponent() -> Template: - return t"""Column 1Column 2""" - - -def test_fragment_from_component(): - # This test assumes that if a component returns a template that parses - # into multiple root elements, they are treated as a fragment. - node = html(t"<{ColumnsComponent} />
    ") - assert node == Element( - "table", - children=[ - Element( - "tr", - children=[ - Element("td", children=[Text("Column 1")]), - Element("td", children=[Text("Column 2")]), - ], - ), - ], - ) - assert str(node) == "
    Column 1Column 2
    " + def test_accepts_children(self): + def DivWrapper( + children: Template, add_classes: list[str] | None = None + ) -> Template: + return t"
    {children}
    " + + callable_info = get_callable_info(DivWrapper) + kwargs = prep_component_kwargs(callable_info, {}, children=t"") + assert tuple(kwargs.keys()) == ("children",) + assert isinstance(kwargs["children"], Template) and kwargs[ + "children" + ].strings == ("",) + + add_classes = ["red"] + kwargs = prep_component_kwargs( + callable_info, {"add_classes": add_classes}, children=t"" + ) + assert set(kwargs.keys()) == {"children", "add_classes"} + assert isinstance(kwargs["children"], Template) and kwargs[ + "children" + ].strings == ("",) + assert kwargs["add_classes"] == add_classes + + def test_no_children(self): + def SpanMaker(content_text: str) -> Template: + return t"{content_text}" + + callable_info = get_callable_info(SpanMaker) + content_text = "inner" + kwargs = prep_component_kwargs( + callable_info, {"content_text": content_text}, children=t"
    " + ) + assert kwargs == {"content_text": content_text} # no children -def test_component_passed_as_attr_value(): - def Wrapper( - children: Iterable[Node], sub_component: Callable, **attrs: t.Any +class TestFunctionComponent: + @staticmethod + def FunctionComponent( + children: Template, first: str, second: int, third_arg: str, **attrs: t.Any ) -> Template: - return t"<{sub_component} {attrs}>{children}" - - node = html( - t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside wrapper

    ' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - "class": "wrapped", - }, - children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])], - ) - assert ( - str(node) - == '
    Component:

    Inside wrapper

    ' - ) - - -def test_nested_component_gh23(): - # See https://github.com/t-strings/tdom/issues/23 for context - def Header(): - return html(t"{'Hello World'}") - - node = html(t"<{Header} />") - assert node == Text("Hello World") - assert str(node) == "Hello World" - - -def test_component_returning_iterable(): - def Items() -> Iterable: - for i in range(2): - yield t"
  • Item {i + 1}
  • " - yield html(t"
  • Item {3}
  • ") - - node = html(t"
      <{Items} />
    ") - assert node == Element( - "ul", - children=[ - Element("li", children=[Text("Item "), Text("1")]), - Element("li", children=[Text("Item "), Text("2")]), - Element("li", children=[Text("Item "), Text("3")]), - ], - ) - assert str(node) == "
    • Item 1
    • Item 2
    • Item 3
    " + # Ensure type correctness of props at runtime for testing purposes + assert isinstance(first, str) + assert isinstance(second, int) + assert isinstance(third_arg, str) + new_attrs = { + "id": third_arg, + "data": {"first": first, "second": second}, + **attrs, + } + return t"
    Component: {children}
    " + + def test_with_children(self): + res = html( + t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' + ) + assert ( + res + == '
    Component: Hello, Component!
    ' + ) + def test_with_no_children(self): + """Same test, but the caller didn't provide any children.""" + res = html( + t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' + ) + assert ( + res + == '
    Component:
    ' + ) -def test_component_returning_fragment(): - def Items() -> Node: - return html(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") + def test_missing_props_error(self): + with pytest.raises(TypeError): + _ = html( + t"<{self.FunctionComponent}>Missing props" + ) - node = html(t"
      <{Items} />
    ") - assert node == Element( - "ul", - children=[ - Element("li", children=[Text("Item "), Text("1")]), - Element("li", children=[Text("Item "), Text("2")]), - Element("li", children=[Text("Item "), Text("3")]), - ], - ) - assert str(node) == "
    • Item 1
    • Item 2
    • Item 3
    " +class TestFunctionComponentNoChildren: + @staticmethod + def FunctionComponentNoChildren( + first: str, second: int, third_arg: str + ) -> Template: + # Ensure type correctness of props at runtime for testing purposes + assert isinstance(first, str) + assert isinstance(second, int) + assert isinstance(third_arg, str) + new_attrs = { + "id": third_arg, + "data": {"first": first, "second": second}, + } + return t"
    Component: ignore children
    " + + def test_interpolated_template_component_ignore_children(self): + res = html( + t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' + ) + assert ( + res + == '
    Component: ignore children
    ' + ) -@dataclass -class ClassComponent: - """Example class-based component.""" - user_name: str - image_url: str - homepage: str = "#" - children: Iterable[Node] = field(default_factory=list) +class TestFunctionComponentKeywordArgs: + @staticmethod + def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: + # Ensure type correctness of props at runtime for testing purposes + assert isinstance(first, str) + assert "children" in attrs + children = attrs.pop("children") + new_attrs = {"data-first": first, **attrs} + return t"
    Component with kwargs: {children}
    " - def __call__(self) -> Node: - return html( - t"
    " - t"" - t"{f" - t"" - t"{self.user_name}" - t"{self.children}" - t"
    ", + def test_children_always_passed_via_kwargs(self): + res = html( + t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content' + ) + assert ( + res + == '
    Component with kwargs: Child content
    ' ) + def test_children_always_passed_via_kwargs_even_when_empty(self): + res = html( + t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />' + ) + assert ( + res == '
    Component with kwargs:
    ' + ) -def test_class_component_implicit_invocation_with_children(): - node = html( - t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - assert node == Element( - "div", - attrs={"class": "avatar"}, - children=[ - Element( - "a", - attrs={"href": "#"}, - children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) - ], - ), - Element("span", children=[Text("Alice")]), - Text("Fun times!"), - ], - ) - assert ( - str(node) - == '
    Avatar of AliceAliceFun times!
    ' - ) - - -def test_class_component_direct_invocation(): - avatar = ClassComponent( - user_name="Alice", - image_url="https://example.com/alice.png", - homepage="https://example.com/users/alice", - ) - node = html(t"<{avatar} />") - assert node == Element( - "div", - attrs={"class": "avatar"}, - children=[ - Element( - "a", - attrs={"href": "https://example.com/users/alice"}, - children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) - ], - ), - Element("span", children=[Text("Alice")]), - ], - ) - assert ( - str(node) - == '
    Avatar of AliceAlice
    ' - ) +class TestComponentSpecialUsage: + @staticmethod + def ColumnsComponent() -> Template: + return t"""Column 1Column 2""" -@dataclass -class ClassComponentNoChildren: - """Example class-based component that does not ask for children.""" + def test_fragment_from_component(self): + # This test assumes that if a component returns a template that parses + # into multiple root elements, they are treated as a fragment. + res = html(t"<{self.ColumnsComponent} />
    ") + assert res == "
    Column 1Column 2
    " - user_name: str - image_url: str - homepage: str = "#" + def test_component_passed_as_attr_value(self): + def Wrapper( + children: Template, sub_component: Callable, **attrs: t.Any + ) -> Template: + return t"<{sub_component} {attrs}>{children}" - def __call__(self) -> Node: - return html( - t"
    " - t"" - t"{f" - t"" - t"{self.user_name}" - t"ignore children" - t"
    ", + res = html( + t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside wrapper

    ' + ) + assert ( + res + == '
    Component:

    Inside wrapper

    ' ) + def test_nested_component_gh23(self): + # @DESIGN: Do we need this? Should we recommend an alternative? + # See https://github.com/t-strings/tdom/issues/23 for context + def Header() -> Template: + return t"{'Hello World'}" + + res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div")) + assert res == "Hello World" + + +class TestClassComponent: + @dataclass + class ClassComponent: + """Example class-based component.""" + + user_name: str + image_url: str + children: Template + homepage: str = "#" + + def __call__(self) -> Template: + return ( + t"
    " + t"" + t"{f" + t"" + t"{self.user_name}" + t"{self.children}" + t"
    " + ) -def test_class_component_implicit_invocation_ignore_children(): - node = html( - t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - assert node == Element( - "div", - attrs={"class": "avatar"}, - children=[ - Element( - "a", - attrs={"href": "#"}, - children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) - ], - ), - Element("span", children=[Text("Alice")]), - Text("ignore children"), - ], - ) - assert ( - str(node) - == '
    Avatar of AliceAliceignore children
    ' - ) + def test_class_component_implicit_invocation_with_children(self): + res = html( + t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + assert ( + res + == '
    Avatar of AliceAliceFun times!
    ' + ) + def test_class_component_direct_invocation(self): + avatar = self.ClassComponent( + user_name="Alice", + image_url="https://example.com/alice.png", + homepage="https://example.com/users/alice", + children=t"", # Children is required so we set it to an empty template. + ) + res = html(t"<{avatar} />") + assert ( + res + == '
    Avatar of AliceAlice
    ' + ) -def AttributeTypeComponent( - data_int: int, - data_true: bool, - data_false: bool, - data_none: None, - data_float: float, - data_dt: datetime.datetime, - **kws: dict[str, object | None], -) -> Template: - """Component to test that we don't incorrectly convert attribute types.""" - assert isinstance(data_int, int) - assert data_true is True - assert data_false is False - assert data_none is None - assert isinstance(data_float, float) - assert isinstance(data_dt, datetime.datetime) - for kw, v_type in [ - ("spread_true", True), - ("spread_false", False), - ("spread_int", int), - ("spread_none", None), - ("spread_float", float), - ("spread_dt", datetime.datetime), - ("spread_dict", dict), - ("spread_list", list), - ]: - if v_type in (True, False, None): - assert kw in kws and kws[kw] is v_type, ( - f"{kw} should be {v_type} but got {kws=}" - ) - else: - assert kw in kws and isinstance(kws[kw], v_type), ( - f"{kw} should instance of {v_type} but got {kws=}" + @dataclass + class ClassComponentNoChildren: + """Example class-based component that does not ask for children.""" + + user_name: str + image_url: str + homepage: str = "#" + + def __call__(self) -> Template: + return ( + t"
    " + t"" + t"{f" + t"" + t"{self.user_name}" + t"ignore children" + t"
    " ) - return t"Looks good!" + + def test_implicit_invocation_ignore_children(self): + res = html( + t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + assert ( + res + == '
    Avatar of AliceAliceignore children
    ' + ) def test_attribute_type_component(): + def AttributeTypeComponent( + data_int: int, + data_true: bool, + data_false: bool, + data_none: None, + data_float: float, + data_dt: datetime.datetime, + **kws: dict[str, object | None], + ) -> Template: + """Component to test that we don't incorrectly convert attribute types.""" + assert isinstance(data_int, int) + assert data_true is True + assert data_false is False + assert data_none is None + assert isinstance(data_float, float) + assert isinstance(data_dt, datetime.datetime) + for kw, v_type in [ + ("spread_true", True), + ("spread_false", False), + ("spread_int", int), + ("spread_none", None), + ("spread_float", float), + ("spread_dt", datetime.datetime), + ("spread_dict", dict), + ("spread_list", list), + ]: + if v_type in (True, False, None): + assert kw in kws and kws[kw] is v_type, ( + f"{kw} should be {v_type} but got {kws=}" + ) + else: + assert kw in kws and isinstance(kws[kw], v_type), ( + f"{kw} should instance of {v_type} but got {kws=}" + ) + return t"Looks good!" + an_int: int = 42 a_true: bool = True a_false: bool = False @@ -1462,31 +1736,370 @@ def test_attribute_type_component(): "spread_dict": {}, "spread_list": ["eggs", "milk"], } - node = html( + res = html( t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} " t"data-false={a_false} data-none={a_none} data-float={a_float} " t"data-dt={a_dt} {spread_attrs}/>" ) - assert node == Text("Looks good!") - assert str(node) == "Looks good!" + assert res == "Looks good!" + + +class TestComponentErrors: + def test_component_non_callable_fails(self): + with pytest.raises(TypeError): + _ = html(t"<{'not a function'} />") + + def test_component_requiring_positional_arg_fails(self): + def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover + return t"

    Positional arg: {whoops}

    " + + with pytest.raises(TypeError): + _ = html(t"<{RequiresPositional} />") + + def test_mismatched_component_closing_tag_fails(self): + def OpenTag(children: Template) -> Template: + return t"
    open
    " + + def CloseTag(children: Template) -> Template: + return t"
    close
    " + + with pytest.raises(TypeError): + _ = html(t"<{OpenTag}>Hello") + + +def test_integration_basic(): + comment_text = "comment is not literal" + interpolated_class = "red" + text_in_element = "text is not literal" + templated = "not literal" + spread_attrs = {"data-on": True} + markup_content = Markup("
    safe
    ") + + def WrapperComponent(children): + return t"
    {children}
    " + + smoke_t = t""" + + + +literal + +{text_in_element} +{text_in_element} +<{WrapperComponent}>comp body +{markup_content} + +""" + smoke_str = """ + + + +literal + +text is not literal +text is not literal +
    comp body
    +
    safe
    + +""" + assert html(smoke_t) == smoke_str + + +def struct_repr(st): + """Breakdown Templates into comparable parts for test verification.""" + return st.strings, tuple( + (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations + ) + + +def test_process_template_internal_cache(): + """Test that cache and non-cache both generally work as expected.""" + # @NOTE: We use a made-up custom element so that we can be sure to + # miss the cache. If this element is used elsewhere than the global + # cache might cache it and it will ruin our counting, specifically + # the first miss will instead be a hit. + sample_t = t"
    {'content'}
    " + sample_diff_t = t"
    {'diffcontent'}
    " + alt_t = t"{'content'}" + process_api = processor_service_factory() + cached_process_api = cached_processor_service_factory() + # Because the cache is stored on the class itself this can be affect by + # other tests, so save this off and take the difference to determine the result, + # this is not great and hopefully we can find a better solution. + assert isinstance(cached_process_api.parser_api, CachedParserService) + start_ci = cached_process_api.parser_api._to_tnode.cache_info() + tnode1 = process_api.parser_api.to_tnode(sample_t) + tnode2 = process_api.parser_api.to_tnode(sample_t) + cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t) + cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t) + cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t) + # Check that the uncached and cached services are actually + # returning non-identical results. + assert tnode1 is not cached_tnode1 + assert tnode1 is not cached_tnode2 + assert tnode1 is not cached_tnode3 + # Check that the uncached service returns a brand new result everytime. + assert tnode1 is not tnode2 + # Check that the cached service is returning the exact same, identical, result. + assert cached_tnode1 is cached_tnode2 + # Even if the input templates are not identical (but are still equivalent). + assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t + # Check that the cached service and uncached services return + # results that are equivalent (even though they are not (id)entical). + assert tnode1 == cached_tnode1 + assert tnode2 == cached_tnode1 + # Now that we are setup we check that the cache is internally + # working as we intended. + ci = cached_process_api.parser_api._to_tnode.cache_info() + # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 + assert ci.hits - start_ci.hits == 2 + # cached_tf1 was a miss because cache was empty (brand new) + assert ci.misses - start_ci.misses == 1 + cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t) + # A different template produces a brand new tf. + assert cached_tnode1 is not cached_tnode4 + # The template is new AND has a different structure so it also + # produces an unequivalent tf. + assert cached_tnode1 != cached_tnode4 + + +def test_repeat_calls(): + """Crude check for any unintended state being kept between calls.""" + + def get_sample_t(idx, spread_attrs, button_text): + return t"""
    """ + + for idx in range(3): + spread_attrs = {"data-enabled": True} + button_text = "PROCESS" + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert ( + html(sample_t) + == f'
    ' + ) + + +def get_select_t_with_list(options, selected_values): + return t"""""" + + +def get_select_t_with_generator(options, selected_values): + return t"""""" + + +def get_select_t_with_concat(options, selected_values): + parts = [t"") + return sum(parts, t"") + + +@pytest.mark.parametrize( + "provider", + ( + get_select_t_with_list, + get_select_t_with_generator, + get_select_t_with_concat, + ), +) +def test_process_template_iterables(provider): + def get_color_select_t(selected_values: set, provider: Callable) -> Template: + PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] + assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS}) + return provider(PRIMARY_COLORS, selected_values) + + no_selection_t = get_color_select_t(set(), provider) + assert ( + html(no_selection_t) + == '' + ) + selected_yellow_t = get_color_select_t({"Y"}, provider) + assert ( + html(selected_yellow_t) + == '' + ) -def test_component_non_callable_fails(): - with pytest.raises(TypeError): - _ = html(t"<{'not a function'} />") +def test_component_integration(): + """Broadly test that common template component usage works.""" + def PageComponent(children, root_attrs=None): + return t"""
    {children}
    """ -def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover - return t"

    Positional arg: {whoops}

    " + def FooterComponent(classes=("footer-default",)): + return t'' + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{FooterComponent} /> + + +""" -def test_component_requiring_positional_arg_fails(): - with pytest.raises(TypeError): - _ = html(t"<{RequiresPositional} />") + content = "HTML never goes out of style." + content_str = html( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" + ) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +class TestInterpolatingHTMLInTemplateWithDynamicParentTag: + """ + When a template does not have a parent tag we cannot determine the type + of text that should be allowed and therefore we cannot determine how to + escape that text. Once the type is known we should escape any + interpolations in that text correctly. + """ + + def test_dynamic_raw_text(self): + """Type raw text should fail because template is already not allowed.""" + content = '' + content_t = t"{content}" + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): + content_t = t'' + _ = html(t"") + + def test_dynamic_escapable_raw_text(self): + """Type escapable raw text should fail because template is already not allowed.""" + content = '' + content_t = t"{content}" + with pytest.raises( + ValueError, match="Recursive includes are not supported within textarea" + ): + _ = html(t"") + + def test_dynamic_normal_text(self): + """Escaping should be applied when normal text type is goes into effect.""" + content = '' + content_t = t"{content}" + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + html(t"
    {content_t}
    ") + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) -def test_mismatched_component_closing_tag_fails(): - with pytest.raises(TypeError): - _ = html( - t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" +class TestPagerComponentExample: + @dataclass + class Pager: + left_pages: tuple = () + page: int = 0 + right_pages: tuple = () + prev_page: int | None = None + next_page: int | None = None + + @dataclass + class PagerDisplay: + pager: TestPagerComponentExample.Pager + paginate_url: Callable[[int], str] + root_classes: tuple[str, ...] = ("cb", "tc", "w-100") + part_classes: tuple[str, ...] = ("dib", "pa1") + + def __call__(self) -> Template: + parts = [t"
    "] + if self.pager.prev_page: + parts.append( + t"Prev" + ) + for left_page in self.pager.left_pages: + parts.append( + t'{left_page}' + ) + parts.append(t"{self.pager.page}") + for right_page in self.pager.right_pages: + parts.append( + t'{right_page}' + ) + if self.pager.next_page: + parts.append( + t"Next" + ) + parts.append(t"
    ") + return Template(*chain.from_iterable(parts)) + + def test_example(self): + def paginate_url(page: int) -> str: + return f"/pages?page={page}" + + def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: + return t"
    <{self.PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " + + pager = self.Pager( + left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None ) + content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />" + res = html(content_t) + print(res) + assert ( + res + == '' + ) + + +def test_mathml(): + num = 1 + denom = 3 + mathml_t = t"""

    + The fraction + + + {num} + {denom} + + + is not a decimal number. +

    """ + res = html(mathml_t) + assert ( + str(res) + == """

    + The fraction + + + 1 + 3 + + + is not a decimal number. +

    """ + ) diff --git a/tdom/svg_test.py b/tdom/svg_test.py index db48236..4b61d04 100644 --- a/tdom/svg_test.py +++ b/tdom/svg_test.py @@ -1,26 +1,28 @@ +from string.templatelib import Template + from tdom import html, svg # svg() — tag case-fixing def test_svg_clippath_case_fixed(): - node = svg(t"") - assert str(node) == '' + result = svg(t"") + assert result == '' def test_svg_lineargradient_case_fixed(): - node = svg(t"") - assert str(node) == '' + result = svg(t"") + assert result == '' def test_svg_femergenode_self_closing_case_fixed(): - node = svg(t"") - assert str(node) == "" + result = svg(t"") + assert result == "" def test_svg_nested_tags_case_fixed(): - node = svg(t"") - assert str(node) == '' + result = svg(t"") + assert result == '' # ------------------------------ @@ -29,50 +31,50 @@ def test_svg_nested_tags_case_fixed(): def test_svg_viewbox_attr_case_fixed(): - node = svg(t'') - assert str(node) == '' + result = svg(t'') + assert result == '' def test_svg_case_sensitivity(): # SVG attributes like viewBox are case-sensitive - node = html(t'') + result = html(t'') # We expect viewBox, not viewbox - assert "viewBox" in str(node) + assert "viewBox" in result def test_svg_tag_case_sensitivity(): # SVG tags like linearGradient are case-sensitive - node = html(t"") - assert "linearGradient" in str(node) + result = html(t"") + assert "linearGradient" in result def test_svg_tag_case_sensitivity_outside_svg(): # Outside SVG, tags should be lowercased - node = html(t"") - assert "lineargradient" in str(node) + result = html(t"") + assert "lineargradient" in result def test_svg_attr_case_sensitivity_outside_svg(): # Outside SVG, attributes should be lowercased - node = html(t'
    ') - assert "viewbox" in str(node) + result = html(t'
    ') + assert "viewbox" in result def test_svg_interpolated_attr(): cx, cy, r = 50, 50, 40 - node = svg(t'') - assert str(node) == '' + result = svg(t'') + assert result == '' def test_svg_interpolated_child(): label = "hello" - node = svg(t"{label}") - assert str(node) == "hello" + result = svg(t"{label}") + assert result == "hello" def test_svg_fragment_multiple_roots(): - node = svg(t"") - assert str(node) == "" + result = svg(t"") + assert result == "" # --------------------------------------------------------- @@ -82,16 +84,16 @@ def test_svg_fragment_multiple_roots(): def test_svg_and_html_produce_different_results_for_same_strings(): # html() lowercases clipPath (no SVG context); svg() preserves it. - html_node = html(t"") - svg_node = svg(t"") - assert str(html_node) == "" - assert str(svg_node) == "" + html_result = html(t"") + svg_result = svg(t"") + assert html_result == "" + assert svg_result == "" def test_html_full_svg_document_still_works(): # html() auto-detects SVG context when is present — no regression. - node = html(t"") - assert str(node) == '' + result = html(t"") + assert result == '' # ------------------------------- @@ -100,11 +102,53 @@ def test_html_full_svg_document_still_works(): def test_svg_fragment_embedded_in_html(): - def icon(): - return svg(t'') + def icon() -> Template: + return t'' + + result = html(t'
    {icon()}
    ') + assert ( + result == '
    ' + ) - node = html(t'
    {icon()}
    ') + +def test_svg_fragment_with_spread_attr(): + def icon(attrs: dict[str, str]) -> Template: + return t"" + + rect_attrs = {"viewbox": "0 0 10 10"} + result = html(t'
    {icon(attrs=rect_attrs)}
    ') assert ( - str(node) - == '
    ' + result == '
    ' ) + + +def test_svg_nesting(): + svg_doc = t""" + + + + + + + + + text,fo,svg,html +
    + + + text,div,fo,svg,html + + + + + text,span,fo,svg,div,fo,svg,html + + + π +
    +
    +
    + +""" + res = html(svg_doc) + assert res.count(" None: + def __init__(self, template: Template) -> None: self.template = template - self.svg_context = svg_context def __eq__(self, other: object) -> bool: if not isinstance(other, CachableTemplate): return NotImplemented - return ( - self.template.strings == other.template.strings - and self.svg_context == other.svg_context - ) + return self.template.strings == other.template.strings def __hash__(self) -> int: - return hash((self.template.strings, self.svg_context)) + return hash(self.template.strings)