Skip to content

Commit

Permalink
escape html attr values, add tests, add escape_attributes option
Browse files Browse the repository at this point in the history
  • Loading branch information
SamDudley committed Jul 21, 2024
1 parent 11c7118 commit def1b87
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 25 deletions.
8 changes: 6 additions & 2 deletions neat_html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,16 @@ def h(
return Element(tag, attrs, children)


def render(elements: Element | Sequence[Element]) -> str:
def render(
elements: Element | Sequence[Element],
*,
escape_attributes: bool = True,
) -> str:
if isinstance(elements, Element):
elements = [elements]

tokens = Tokenizer().tokenize(elements)
return Compiler().compile(tokens)
return Compiler(escape_attributes=escape_attributes).compile(tokens)


def safe(string: str) -> SafeString:
Expand Down
48 changes: 31 additions & 17 deletions neat_html/compiler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from collections import deque
from html import escape
from typing import TYPE_CHECKING
from html import escape as html_escape
from typing import TYPE_CHECKING, TypedDict, Unpack

from .tokens import ClosingTag, Content, OpeningTag, Token
from .types import SafeString
Expand All @@ -9,7 +9,14 @@
from .types import HtmlAttributes


class CompilerOptions(TypedDict):
escape_attributes: bool


class Compiler:
def __init__(self, **options: Unpack[CompilerOptions]) -> None:
self.options = options

def compile(self, tokens: deque[Token]) -> str:
self.tokens = tokens

Expand Down Expand Up @@ -54,11 +61,7 @@ def visit_OpeningTag(self, tag: OpeningTag) -> None:
self.append(f"<{tag.name}{attrs}>")

def visit_Content(self, content: Content) -> None:
string = (
content.string
if isinstance(content.string, SafeString)
else escape(content.string)
)
string = self.escape(content.string)
self.append(string)

def visit_ClosingTag(self, tag: ClosingTag) -> None:
Expand Down Expand Up @@ -86,15 +89,13 @@ def move_cursor(self, token: Token, next_token: Token | None) -> None:
self.newline()
self.indent()

@classmethod
def render_attrs(cls, attrs: "HtmlAttributes") -> str:
def render_attrs(self, attrs: "HtmlAttributes") -> str:
if not attrs:
return ""
attrs_list = [cls.render_attr(k, v) for k, v in attrs.items()]
attrs_list = [self.render_attr(k, v) for k, v in attrs.items()]
return " ".join(attrs_list)

@classmethod
def render_attr(cls, key: str, value: object) -> str:
def render_attr(self, key: str, value: object) -> str:
if value == "":
return key

Expand All @@ -108,13 +109,26 @@ def render_attr(cls, key: str, value: object) -> str:
if not isinstance(value, dict):
raise ValueError("A style value must be a dict")

return f'{key}="{cls.render_style(value)}"'
return f'{key}="{self.render_style(value)}"'

# Default
return f'{key}="{value}"'
return f'{key}="{self.render_attr_value(value)}"'

@classmethod
def render_style(cls, style: dict[str, str]) -> str:
def render_style(self, style: dict[str, str]) -> str:
if not style:
return ""
style_list = [f"{k}: {v}" for k, v in style.items()]
style_list = [f"{k}: {self.render_attr_value(v)}" for k, v in style.items()]
return "; ".join(style_list)

def render_attr_value(self, value: object) -> str:
if self.options["escape_attributes"] is False:
return str(value)

if not isinstance(value, (str, SafeString)):
value = str(value)

return self.escape(value)

@staticmethod
def escape(string: str) -> str:
return string if isinstance(string, SafeString) else html_escape(string)
33 changes: 27 additions & 6 deletions tests/test_dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

from neat_html import h, render, safe

HTML_SCRIPT = "<script>alert(1)</script>"
HTML_SCRIPT_ESCAPED = "&lt;script&gt;alert(1)&lt;/script&gt;"


def test_single_tag() -> None:
html = render(h("div", {}, []))
Expand Down Expand Up @@ -74,14 +77,32 @@ def test_content_is_escaped() -> None:
)


def test_attribute_value_can_be_not_str() -> None:
html = render(h("input", {"tabindex": 0}))
assert html == '<input tabindex="0">\n'


def test_attributes_are_escaped() -> None:
html = render(h("div", {"id": "<script>alert(1)</script>"}))
assert html == dedent(
"""\
<div id="&lt;script&gt;alert(1)&lt;/script&gt;">
</div>
"""
html = render(h("h1", {"class": HTML_SCRIPT}))
assert html == f'<h1 class="{HTML_SCRIPT_ESCAPED}"></h1>\n'


def test_style_attributes_are_escaped() -> None:
html = render(h("h1", {"style": {"color": HTML_SCRIPT}}))
assert html == f'<h1 style="color: {HTML_SCRIPT_ESCAPED}"></h1>\n'


def test_safe_attribute_value_is_not_escaped() -> None:
html = render(h("h1", {"style": {"color": safe(HTML_SCRIPT)}}))
assert html == f'<h1 style="color: {HTML_SCRIPT}"></h1>\n'


def test_attribute_escaping_can_be_turned_off_with_option() -> None:
html = render(
h("h1", {"style": {"color": safe(HTML_SCRIPT)}}),
escape_attributes=False,
)
assert html == f'<h1 style="color: {HTML_SCRIPT}"></h1>\n'


def test_empty_string_boolean_attribute() -> None:
Expand Down

0 comments on commit def1b87

Please sign in to comment.