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"