From 5b148c38948dc685f1d5c0c0de4913d2689bd85a Mon Sep 17 00:00:00 2001 From: keithasaurus <592217+keithasaurus@users.noreply.github.com> Date: Thu, 7 Dec 2023 21:26:30 -0800 Subject: [PATCH] helper to render inline css styles (#10) * wip * tests and docs * wip * fix * fix Dict type * update docs --- README.md | 23 ++++ pyproject.toml | 2 +- simple_html/__init__.py | 243 +++++++++++++++++++++++++++++++++++++- tests/test_simple_html.py | 56 ++++++--- 4 files changed, 307 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f3a3c50..d589b16 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,29 @@ render( #
``` +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") +) +#
cool
+ + +# ints and floats are legal values +styles = render_styles({"padding": 0, "flex-grow": 0.6}) + +render( + div({"style": styles}, + "wow") +) +#
wow
+``` + Lists and generators are both valid collections of nodes: ```python diff --git a/pyproject.toml b/pyproject.toml index 284162d..e8a126e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] diff --git a/simple_html/__init__.py b/simple_html/__init__.py index fbae2c4..b36e311 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -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: @@ -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, @@ -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) diff --git a/tests/test_simple_html.py b/tests/test_simple_html.py index 0d29484..c75178f 100644 --- a/tests/test_simple_html.py +++ b/tests/test_simple_html.py @@ -19,7 +19,7 @@ Node, DOCTYPE_HTML5, render, - escape_attribute_key, + escape_attribute_key, render_styles, ) @@ -114,8 +114,8 @@ def test_kw_attributes() -> None: node = div({"class": "first", "name": "some_name", "style": "color:blue;"}, "okok") assert ( - render(node) - == '
okok
' + render(node) + == '
okok
' ) @@ -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!"), [])) - == "
hello World!
" + render(div({}, [], "hello ", [], span({}, "World!"), [])) + == "
hello World!
" ) @@ -175,24 +175,24 @@ def test_escape_key() -> None: assert escape_attribute_key("=") == "=" assert escape_attribute_key("`") == "`" assert ( - escape_attribute_key("something with spaces") - == "something with spaces" + escape_attribute_key("something with spaces") + == "something with spaces" ) def test_render_with_escaped_attributes() -> None: assert ( - render(div({'onmousenter="alert(1)" noop': "1"})) - == '
' + render(div({'onmousenter="alert(1)" noop': "1"})) + == '
' ) assert ( - render(span({"": "\">"})) - == '' + render(span({"": "\">"})) + == '' ) # vals and keys escape slightly differently assert ( - render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'})) - == '
' + render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'})) + == '
' ) @@ -200,6 +200,32 @@ def test_render_with_safestring_attributes() -> None: bad_key = 'onmousenter="alert(1)" noop' bad_val = "" assert ( - render(div({SafeString(bad_key): SafeString(bad_val)})) - == f'
' + render(div({SafeString(bad_key): SafeString(bad_val)})) + == f'
' + ) + + +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")) == '
cool
' + + +def test_render_styles_escapes() -> None: + assert render_styles({'"><': "><>\""}) == SafeString( + safe_str='"><:><>";' )