From dc393919e399fd6511da90dddc242a7b59b21309 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:08:52 +0000 Subject: [PATCH] feat: add HTML wrapper to safely bypass default escaping - Introduce `HTML` class (subclass of `str`) to flag content as safe. - Update `GTag._eval_child` in core renderer to skip `html.escape` for `HTML` instances. - Expose `HTML` in `htag/__init__.py`. - Add unit tests verifying correct escape bypassing. - Add an example application (`examples/markdown_example.py`) demonstrating usage with `mistune`. Co-authored-by: manatlan <150722+manatlan@users.noreply.github.com> --- examples/markdown_example.py | 53 ++++++++++++++++++++++++++++++++++++ htag/__init__.py | 3 +- htag/core.py | 11 ++++++++ pyproject.toml | 1 + tests/test_core.py | 25 +++++++++++++++++ uv.lock | 16 ++++++++++- 6 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 examples/markdown_example.py diff --git a/examples/markdown_example.py b/examples/markdown_example.py new file mode 100644 index 0000000..5cef725 --- /dev/null +++ b/examples/markdown_example.py @@ -0,0 +1,53 @@ +import htag +from htag import Tag, App, HTML +import mistune + +class MarkdownViewer(Tag.div): + """ + A simple component that renders Markdown text to raw HTML. + It uses the `HTML` string wrapper to prevent `htag` from escaping the rendered HTML. + """ + def __init__(self, markdown_text: str, **kwargs): + super().__init__(**kwargs) + + # We process the markdown into an HTML string + rendered_html_string = mistune.html(markdown_text) + + # We wrap the result in htag.HTML to mark it as safe, avoiding XSS escapes + # and we append it as a child to our component. + self += HTML(rendered_html_string) + +class MarkdownExample(App): + """ Main Application demonstrating the markdown bypass. """ + def init(self): + self += Tag.h1("Markdown Render Example") + self += Tag.p("The following block is rendered from markdown:") + + # An example of markdown content + md_content = ''' +## Hello Markdown! + +This is a **bold** statement and this is *italic*. + +- Item 1 +- Item 2 +- Item 3 + +You can even add code blocks: +```python +print("Hello World") +``` + ''' + + # Apply the markdown viewer component with some CSS styles + self += MarkdownViewer( + markdown_text=md_content, + style="border: 1px solid #ccc; padding: 10px; background-color: #f9f9f9;" + ) + +if __name__ == "__main__": + import uvicorn + from htag import WebApp + + app = WebApp(MarkdownExample) + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/htag/__init__.py b/htag/__init__.py index 7592de0..55cb403 100644 --- a/htag/__init__.py +++ b/htag/__init__.py @@ -1,4 +1,4 @@ -from .core import prevent, stop, State, States, current_request +from .core import prevent, stop, State, States, current_request, HTML from .tag import Tag from .runner import AppRunner as App from .web import WebApp @@ -20,6 +20,7 @@ __all__ = [ "__version__", + "HTML", # safe string wrapper "Tag", # the main thing "State", # State management "States", # Multi-State management container diff --git a/htag/core.py b/htag/core.py index b777707..e8f84d0 100644 --- a/htag/core.py +++ b/htag/core.py @@ -15,6 +15,15 @@ logger = logging.getLogger("htag") +class HTML(str): + """ + A string subclass to mark HTML code as safe so it is not escaped + when rendered in a GTag. + """ + pass + + + from .css import _scope_css, _scoped_style_cache @@ -883,6 +892,8 @@ def collect(item: Any) -> None: if stringify: if isinstance(child, GTag) or (self.tag in ("style", "script")): return str(child) + if isinstance(child, HTML): + return str(child) return html.escape(str(child)) return child diff --git a/pyproject.toml b/pyproject.toml index 296c577..b9eeb60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ Repository = "https://github.com/manatlan/htag.git" [dependency-groups] dev = [ "httpx>=0.28.1", + "mistune>=3.2.0", "mkdocs-material>=9.7.1", "mkdocstrings[python]>=1.0.3", "mypy>=1.19.1", diff --git a/tests/test_core.py b/tests/test_core.py index e37674d..b9d3a1e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -808,3 +808,28 @@ def my_handler(): nonlocal called; called = True cb = prevent(my_handler) cb() assert called is True + +def test_html_escape_bypass(): + from htag import Tag, HTML + + # Standard string is escaped + t = Tag.div("test") + assert "<b>test</b>" in str(t) + assert "" not in str(t) + + # HTML wrapper bypasses escape + t2 = Tag.div(HTML("test")) + assert "test" in str(t2) + assert "<b>" not in str(t2) + + # Mix and match via __add__ + t3 = Tag.div() + t3 += "hello" + t3 += HTML("world") + assert "<i>hello</i>" in str(t3) + assert "world" in str(t3) + + # Mix and match via childs + t4 = Tag.div(["hello", HTML("world")]) + assert "<i>hello</i>" in str(t4) + assert "world" in str(t4) diff --git a/uv.lock b/uv.lock index 0bec761..e64ca46 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -400,6 +400,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx" }, + { name = "mistune" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, @@ -421,6 +422,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "mistune", specifier = ">=3.2.0" }, { name = "mkdocs-material", specifier = ">=9.7.1" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "mypy", specifier = ">=1.19.1" }, @@ -678,6 +680,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mistune" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1"