Skip to content

Commit

Permalink
helper to render inline css styles (#10)
Browse files Browse the repository at this point in the history
* wip

* tests and docs

* wip

* fix

* fix Dict type

* update docs
  • Loading branch information
keithasaurus authored Dec 8, 2023
1 parent c313f2e commit 5b148c3
Show file tree
Hide file tree
Showing 4 changed files with 307 additions and 17 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ render(
# <div empty-str-attribute="" key-only-attr></div>
```

You can render inline css styles with `render_styles`:
```python
from simple_html import div, render, render_styles

styles = render_styles({"min-width": "25px"})

render(
div({"style": styles},
"cool")
)
# <div style="min-width:25px;">cool</div>


# ints and floats are legal values
styles = render_styles({"padding": 0, "flex-grow": 0.6})

render(
div({"style": styles},
"wow")
)
# <div style="padding:0;flex-grow:0.6;">wow</div>
```


Lists and generators are both valid collections of nodes:
```python
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "simple-html"
version = "1.1.1"
version = "1.2.0"
readme = "README.md"
description = "Template-less html rendering in Python"
authors = ["Keith Philpott <[email protected]>"]
Expand Down
243 changes: 242 additions & 1 deletion simple_html/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from html import escape
from types import GeneratorType
from typing import Tuple, Union, Dict, List, FrozenSet, Generator, Iterable
from typing import Tuple, Union, Dict, List, FrozenSet, Generator, Iterable, Any


class SafeString:
Expand All @@ -12,6 +12,12 @@ def __init__(self, safe_str: str) -> None:
def __hash__(self) -> int:
return hash(f"SafeString__{self.safe_str}")

def __eq__(self, other: Any) -> bool:
return isinstance(other, SafeString) and other.safe_str == self.safe_str

def __repr__(self) -> str:
return f"SafeString(safe_str='{self.safe_str}')"


Node = Union[
str,
Expand Down Expand Up @@ -273,6 +279,241 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None:
raise TypeError(f"Got unknown type: {type(node)}")


_common_safe_css_props = frozenset(
{
"color",
"border",
"margin",
"font-style",
"transform",
"background-color",
"align-content",
"align-items",
"align-self",
"all",
"animation",
"animation-delay",
"animation-direction",
"animation-duration",
"animation-fill-mode",
"animation-iteration-count",
"animation-name",
"animation-play-state",
"animation-timing-function",
"backface-visibility",
"background",
"background-attachment",
"background-blend-mode",
"background-clip",
"background-color",
"background-image",
"background-origin",
"background-position",
"background-repeat",
"background-size",
"border",
"border-bottom",
"border-bottom-color",
"border-bottom-left-radius",
"border-bottom-right-radius",
"border-bottom-style",
"border-bottom-width",
"border-collapse",
"border-color",
"border-image",
"border-image-outset",
"border-image-repeat",
"border-image-slice",
"border-image-source",
"border-image-width",
"border-left",
"border-left-color",
"border-left-style",
"border-left-width",
"border-radius",
"border-right",
"border-right-color",
"border-right-style",
"border-right-width",
"border-spacing",
"border-style",
"border-top",
"border-top-color",
"border-top-left-radius",
"border-top-right-radius",
"border-top-style",
"border-top-width",
"border-width",
"bottom",
"box-shadow",
"box-sizing",
"caption-side",
"caret-color",
"@charset",
"clear",
"clip",
"clip-path",
"color",
"column-count",
"column-fill",
"column-gap",
"column-rule",
"column-rule-color",
"column-rule-style",
"column-rule-width",
"column-span",
"column-width",
"columns",
"content",
"counter-increment",
"counter-reset",
"cursor",
"direction",
"display",
"empty-cells",
"filter",
"flex",
"flex-basis",
"flex-direction",
"flex-flow",
"flex-grow",
"flex-shrink",
"flex-wrap",
"float",
"font",
"@font-face",
"font-family",
"font-kerning",
"font-size",
"font-size-adjust",
"font-stretch",
"font-style",
"font-variant",
"font-weight",
"grid",
"grid-area",
"grid-auto-columns",
"grid-auto-flow",
"grid-auto-rows",
"grid-column",
"grid-column-end",
"grid-column-gap",
"grid-column-start",
"grid-gap",
"grid-row",
"grid-row-end",
"grid-row-gap",
"grid-row-start",
"grid-template",
"grid-template-areas",
"grid-template-columns",
"grid-template-rows",
"height",
"hyphens",
"@import",
"justify-content",
"@keyframes",
"left",
"letter-spacing",
"line-height",
"list-style",
"list-style-image",
"list-style-position",
"list-style-type",
"margin",
"margin-bottom",
"margin-left",
"margin-right",
"margin-top",
"max-height",
"max-width",
"@media",
"min-height",
"min-width",
"object-fit",
"object-position",
"opacity",
"order",
"outline",
"outline-color",
"outline-offset",
"outline-style",
"outline-width",
"overflow",
"overflow-x",
"overflow-y",
"padding",
"padding-bottom",
"padding-left",
"padding-right",
"padding-top",
"page-break-after",
"page-break-before",
"page-break-inside",
"perspective",
"perspective-origin",
"pointer-events",
"position",
"quotes",
"right",
"scroll-behavior",
"table-layout",
"text-align",
"text-align-last",
"text-decoration",
"text-decoration-color",
"text-decoration-line",
"text-decoration-style",
"text-indent",
"text-justify",
"text-overflow",
"text-shadow",
"text-transform",
"top",
"transform",
"transform-origin",
"transform-style",
"transition",
"transition-delay",
"transition-duration",
"transition-property",
"transition-timing-function",
"user-select",
"vertical-align",
"visibility",
"white-space",
"width",
"word-break",
"word-spacing",
"word-wrap",
"writing-mode",
"z-index",
}
)


def render_styles(
styles: Dict[Union[str, SafeString], Union[str, int, float, SafeString]]
) -> SafeString:
ret = ""
for k, v in styles.items():
if k not in _common_safe_css_props:
if isinstance(k, SafeString):
k = k.safe_str
else:
k = escape(k, True)

if isinstance(v, SafeString):
v = v.safe_str
elif isinstance(v, str):
v = escape(v, True)
# note that ints and floats pass through these condition checks

ret += f"{k}:{v};"

return SafeString(ret)


def render(*nodes: Node) -> str:
results: List[str] = []
_render(nodes, results)
Expand Down
56 changes: 41 additions & 15 deletions tests/test_simple_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
Node,
DOCTYPE_HTML5,
render,
escape_attribute_key,
escape_attribute_key, render_styles,
)


Expand Down Expand Up @@ -114,8 +114,8 @@ def test_kw_attributes() -> None:
node = div({"class": "first", "name": "some_name", "style": "color:blue;"}, "okok")

assert (
render(node)
== '<div class="first" name="some_name" style="color:blue;">okok</div>'
render(node)
== '<div class="first" name="some_name" style="color:blue;">okok</div>'
)


Expand Down Expand Up @@ -156,8 +156,8 @@ def test_render_kw_attribute_with_none() -> None:
def test_can_render_empty() -> None:
assert render([]) == ""
assert (
render(div({}, [], "hello ", [], span({}, "World!"), []))
== "<div>hello <span>World!</span></div>"
render(div({}, [], "hello ", [], span({}, "World!"), []))
== "<div>hello <span>World!</span></div>"
)


Expand All @@ -175,31 +175,57 @@ def test_escape_key() -> None:
assert escape_attribute_key("=") == "&#x3D;"
assert escape_attribute_key("`") == "&#x60;"
assert (
escape_attribute_key("something with spaces")
== "something&nbsp;with&nbsp;spaces"
escape_attribute_key("something with spaces")
== "something&nbsp;with&nbsp;spaces"
)


def test_render_with_escaped_attributes() -> None:
assert (
render(div({'onmousenter="alert(1)" noop': "1"}))
== '<div onmousenter&#x3D;&quot;alert(1)&quot;&nbsp;noop="1"></div>'
render(div({'onmousenter="alert(1)" noop': "1"}))
== '<div onmousenter&#x3D;&quot;alert(1)&quot;&nbsp;noop="1"></div>'
)
assert (
render(span({"<script>\"</script>": "\">"}))
== '<span &lt;script&gt;&quot;&lt;/script&gt;="&quot;&gt;"></span>'
render(span({"<script>\"</script>": "\">"}))
== '<span &lt;script&gt;&quot;&lt;/script&gt;="&quot;&gt;"></span>'
)
# vals and keys escape slightly differently
assert (
render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'}))
== '<div onmousenter&#x3D;&quot;alert(1)&quot;&nbsp;noop="onmousenter=&quot;alert(1)&quot; noop"></div>'
render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'}))
== '<div onmousenter&#x3D;&quot;alert(1)&quot;&nbsp;noop="onmousenter=&quot;alert(1)&quot; noop"></div>'
)


def test_render_with_safestring_attributes() -> None:
bad_key = 'onmousenter="alert(1)" noop'
bad_val = "<script></script>"
assert (
render(div({SafeString(bad_key): SafeString(bad_val)}))
== f'<div {bad_key}="{bad_val}"></div>'
render(div({SafeString(bad_key): SafeString(bad_val)}))
== f'<div {bad_key}="{bad_val}"></div>'
)


def test_safestring_repr() -> None:
assert repr(SafeString("abc123")) == "SafeString(safe_str='abc123')"


def test_safe_string_eq() -> None:
assert "abc123" != SafeString("abc123")
assert SafeString("a") != SafeString("abc123")
assert SafeString("abc123") == SafeString("abc123")


def test_render_styles() -> None:
assert render_styles({}) == SafeString("")
assert render_styles({"abc": 123.45}) == SafeString("abc:123.45;")
assert render_styles({"padding": 0,
"margin": "0 10"}) == SafeString("padding:0;margin:0 10;")

assert render(div({"style": render_styles({"min-width": "25px"})},
"cool")) == '<div style="min-width:25px;">cool</div>'


def test_render_styles_escapes() -> None:
assert render_styles({'"><': "><>\""}) == SafeString(
safe_str='&quot;&gt;&lt;:&gt;&lt;&gt;&quot;;'
)

0 comments on commit 5b148c3

Please sign in to comment.