Architecture¶
Overview¶
PEP 750 template strings for HTML and T-HTML.
The template is parsed into an AST. Interpolated values are validated and escaped, then placed into the AST. The output is always well-formed HTML, and XSS is not possible.
from html_tstring import render_html
name = "<script>alert('xss')</script>"
safe = render_html(t"<div>{name}</div>")
# <div><script>alert('xss')</script></div>
Layers¶
┌─────────────────────────────────────────────────────────┐
│ Python API │
│ html-tstring / thtml-tstring │
│ render_html() / html() / thtml() / @component │
└──────────────────────┬──────────────────────────────────┘
│ PEP 750 Template / Renderable
┌──────────────────────▼──────────────────────────────────┐
│ PyO3 Bindings (tstring-html-bindings) │
│ Template extraction, LRU cache, runtime bridging │
│ Fragment, RawHtml, Renderable, exception classes │
└──────────────────────┬──────────────────────────────────┘
│ TemplateInput + RuntimeContext
┌──────────────────────▼──────────────────────────────────┐
│ Rust Parsers │
│ tstring-html-rs (HTML) / tstring-thtml-rs (T-HTML) │
│ ↕ │
│ tstring-syntax (spans, diagnostics, TemplateInput) │
└─────────────────────────────────────────────────────────┘
Python API (html-tstring, thtml-tstring)¶
Thin wrappers over the Rust bindings. They check that the input is a Template, call into Rust, and return the result:
# html-tstring
render_html(template | renderable) -> str
render_fragment(template | renderable) -> str
html(template) -> Renderable # deferred
check_template(template) -> None
format_template(template) -> str
compile_template(template) -> CompiledHtmlTemplate
# thtml-tstring
thtml(template, *, globals=None, locals=None) -> Renderable # deferred
html(template, *, globals=None, locals=None) -> str # eager
render_html(template, *, globals=None, locals=None) -> str # eager
check_template(template) -> None
format_template(template) -> str
compile_template(template) -> CompiledThtmlTemplate
Renderable¶
Renderable is a shared Python type that represents safe, render-ready HTML built from a parsed template. It is produced by thtml(), html() (from html-tstring), or the @component decorator's auto-wrap.
Unlike RawHtml (which takes an arbitrary external string on trust), Renderable is always built from a validated template. The renderer embeds it as HTML without escaping.
Renderable holds:
- The backend kind (
"html"or"thtml") - The original PEP 750
Template - For T-HTML: captured
globals/localsfor component resolution
The @component decorator checks the return value of the wrapped function. If it is a Template, the decorator converts it to a Renderable. Other types pass through.
PyO3 Bindings (tstring-html-bindings)¶
One crate that is both the PyO3 adapter and the maturin build target:
| Responsibility | Details |
|---|---|
| Template extraction | Converts PEP 750 Template to TemplateInput with runtime values |
| Parse cache | 256-entry LRU per backend (HTML / T-HTML) |
| Component resolution | Frame inspection for caller scope, explicit override |
| Shared primitives | Fragment, RawHtml, Renderable, exception hierarchy |
| Contract | __contract_version__ = 1 with symbol list |
When the bindings encounter a Renderable as a runtime value (in children, component returns, etc.), they call .render() and treat the result as safe HTML, similar to RawHtml.
Rust Parsers¶
tstring-syntax (external crate) defines shared types:
struct TemplateInput { segments: Vec<TemplateSegment> }
struct TemplateInterpolation { expression, conversion, format_spec, raw_source }
struct SourceSpan { start: SourcePosition, end: SourcePosition }
enum ErrorKind { Parse, Semantic, Unrepresentable }
tstring-html-rs is the core crate:
| Part | What it does |
|---|---|
| Lexer/Parser | Token-based streaming parser, classifies tags as HTML or component |
| AST | Document, Element, ComponentTag, Attribute, SpreadAttribute, RawTextElement, etc. |
| Validation | Rejects component tags, unquoted dynamic attrs, raw-text interpolation |
| Formatter | Reconstructs source from AST using raw_source |
| Renderer | HTML escaping, class normalization, spread merge, boolean attrs |
| Escaping | & < > " |
tstring-thtml-rs adds component semantics:
| Part | What it does |
|---|---|
| Validation | Allows component tags, validates attributes and raw-text rules |
| Formatting | T-HTML formatter with component syntax |
| Compile | CompiledThtmlTemplate with the parsed document |
Renderable lives entirely in the Python / bindings layer. The Rust backend does not know about it.
Data Flow¶
Template (Python t-string)
→ extract strings + interpolations (PyO3)
→ build TemplateInput + RuntimeContext
→ Rust parser: build HTML AST with interpolation slots
→ semantic validation
→ render: walk AST, escape values, normalize attributes
→ output: safe HTML string
For T-HTML with components:
Template (Python t-string)
→ extract + parse (same as HTML)
→ walk AST in bindings layer
→ ComponentTag → resolve callable from captured scope
→ call component(**props, children=rendered_children)
→ if return is Template → @component wraps to Renderable → .render()
→ normalize return value → render to HTML
→ non-component nodes → delegate to HTML renderer
Project Layout¶
rust/
tstring-html-rs/ HTML parser, AST, renderer, formatter
tstring-thtml-rs/ T-HTML semantic layer
tstring-html-bindings/ PyO3 extension module + shared Python types
backend-e2e-tests/ Cross-crate integration tests
html-tstring/ Python HTML package
thtml-tstring/ Python T-HTML package
conformance/ Conformance manifests
examples/ Runnable examples
Design Decisions¶
Why parse first?¶
String concatenation can produce broken HTML and is vulnerable to XSS. Parsing the template before inserting values makes both problems impossible.
Renderable vs RawHtml¶
Both bypass escaping when embedded in HTML, but they serve different purposes:
Renderableis produced by the renderer itself (viathtml(),html(), or@component). It is always built from a parsed, validated template. Safe by construction.RawHtmlis an escape hatch for external HTML strings that the user guarantees are safe. No parsing or validation happens.
Before Renderable, component composition required RawHtml(render_html(t"...")). Now components just return t"..." and the decorator handles the rest.
AST-direct rendering¶
In v1, CompiledHtmlTemplate and CompiledThtmlTemplate wrap the parsed AST directly. Rendering walks the AST on each call. A compiled IR with static text ops and dynamic slots is a future optimization. The opaque CompiledTemplate types make this a non-breaking change.
Parse cache¶
template.strings (the static parts) is the cache key. The same template with different values reuses the parsed AST. 256-entry LRU per backend.
Component resolution and scope capture¶
T-HTML components are resolved by name. thtml() captures the caller's scope at creation time using sys._getframe(1). The @component decorator uses the decorated function's module globals as default scope.
Explicit globals=/locals= overrides are available for tests and framework integration. The scope is frozen at creation time and not re-inspected at render time.
External tool integration¶
When the backend is used outside the Python runtime (e.g. by t-linter), callers provide TemplateInput directly:
check_templatevalidates structure and returns diagnostics with source spansformat_templatere-renders canonical HTML/T-HTML, preserving interpolation source fromraw_source- Missing
raw_sourceis a semantic error
Error model¶
Errors carry Diagnostic objects with source spans and expression labels.
Dependencies¶
Python¶
Rust¶
tstring-html-bindings
├─ tstring-html-rs ─── tstring-syntax
└─ tstring-thtml-rs ── tstring-html-rs + tstring-syntax
Notable versions: pyo3 0.27.1, tstring-syntax 0.2.1, Rust edition 2024.
Escaping Policy¶
| Context | Escaping |
|---|---|
| Text children | & < > |
| Attribute values | & < > " (always double-quoted) |
RawHtml |
No escaping (external trusted content) |
Renderable |
No escaping (renderer-built safe HTML) |
| Raw-text elements | Interpolation rejected in v1 |
Testing¶
Build & CI¶
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
push, PR | test, lint, coverage, build, twine check |
docs.yaml |
push, PR (docs), release | build docs, deploy to Cloudflare Pages |
publish-python.yml |
tag push | builds Python artifacts, then publishes tstring-html-bindings, html-tstring, and thtml-tstring via separate Trusted Publisher environments |
publish-rust.yml |
tag push | crates.io |