HTML Templating with T-Strings

Introduction

Python 3.14 introduced t-strings (PEP 750), a new string prefix that creates Template objects instead of performing immediate string interpolation. Quixote provides the htmlformat() function which takes a t-string and returns an htmltext instance with automatic HTML escaping of interpolated values.

This approach provides the same safety benefits as PTL's HTML templates (see doc/PTL.txt) but uses standard Python syntax -- no import hook or .ptl file extension is needed.

Basic Usage

The htmlformat() function is typically imported with the short alias H:

from quixote.html import htmlformat as H

It accepts a t-string (prefixed with t) and returns htmltext. The literal parts of the t-string are treated as trusted HTML markup, while interpolated values are automatically escaped:

>>> from quixote.html import htmlformat as H
>>> title = 'Dissertation on <HEAD>'
>>> H(t'<h1>{title}</h1>')
<htmltext '<h1>Dissertation on &lt;HEAD&gt;</h1>'>

The htmlformat() function only accepts t-strings. Passing a regular string will raise a TypeError.

Writing Template Functions

For functions that build up HTML from multiple parts, use htmltemplate() to create an accumulator. This returns a TemplateIO object in HTML mode. Append to it with += and call .getvalue() to retrieve the final htmltext result:

from quixote.html import htmlformat as H
from quixote.html import htmltemplate

def meta_tags(title, description):
    ht = htmltemplate()
    ht += H(t'<title>{title}</title>')
    ht += H(t'<meta name="description" content="{description}">\n')
    return ht.getvalue()

This is equivalent to the following PTL template:

@ptl_html
def meta_tags(title, description):
    F'<title>{title}</title>'
    F'<meta name="description" content="{description}">\n'

In both cases, the title and description values are automatically HTML-escaped:

>>> meta_tags('Catalog', 'A catalog of our cool products')
<htmltext '<title>Catalog</title>
  <meta name="description" content="A catalog of our cool products">\n'>
>>> meta_tags('Dissertation on <HEAD>',
...           'Discusses the "LINK" and "META" tags')
<htmltext '<title>Dissertation on &lt;HEAD&gt;</title>
  <meta name="description"
   content="Discusses the &quot;LINK&quot; and &quot;META&quot; tags">\n'>

Control flow works naturally since the code is regular Python:

from quixote.html import htmlformat as H
from quixote.html import htmltemplate

def user_list(users):
    ht = htmltemplate()
    ht += H(t'<ul>\n')
    for user in users:
        ht += H(t'  <li>{user.name}</li>\n')
    ht += H(t'</ul>\n')
    return ht.getvalue()

For simple cases that produce a single piece of HTML, you can use htmlformat() directly without htmltemplate():

def format_title(title):
    return H(t'<h1>{title}</h1>')

Including Raw HTML

If you need to include HTML markup from an external source (e.g. a file or database), wrap it with htmltext() before interpolating:

from quixote.html import htmlformat as H
from quixote.html import htmltext

def render_page(body_html):
    safe_body = htmltext(body_html)
    return H(t'<html><body>{safe_body}</body></html>')

Values that are already htmltext instances will not be double-escaped.

As with PTL, use htmltext() sparingly -- every call is an assertion that the data is safe and does not contain malicious HTML.

Converting PTL Files to Python

Two scripts are provided for converting existing .ptl files to standard Python using t-strings:

  1. tools/fstring_to_tstring.py -- Converts uppercase F"..." literals (PTL's HTML f-string syntax) to H(t"...") calls.
  2. tools/ptl_to_py.py -- Converts @ptl_html and @ptl_plain decorated functions to regular Python, making the implicit accumulator explicit.

The recommended conversion procedure is:

  1. Run fstring_to_tstring.py first to convert F-string literals:

    python tools/fstring_to_tstring.py -w yourmodule.ptl
    
  2. Run ptl_to_py.py to convert the decorated functions:

    python tools/ptl_to_py.py -w yourmodule.ptl
    
  3. Rename .ptl files to .py (if not already done by your workflow).

  4. Use ruff to clean up formatting and imports:

    ruff check --fix yourmodule.py
    ruff format yourmodule.py
    

Both scripts support a -v flag to preview changes on stdout without modifying files. Without -w, the converted source is printed to stdout for review. When -w is used, the original file is saved with a ~ suffix as a backup.

Example

Given a PTL file header.ptl:

from quixote.ptl import ptl_html

@ptl_html
def page_header(title, css_url):
    F'<head>\n'
    F'  <title>{title}</title>\n'
    F'  <link rel="stylesheet" href="{css_url}">\n'
    F'</head>\n'

After running both conversion scripts, the result is:

from quixote.html import htmlformat as H
from quixote.html import htmltemplate

def page_header(title, css_url):
    ht = htmltemplate()
    ht += H(t'<head>\n')
    ht += H(t'  <title>{title}</title>\n')
    ht += H(t'  <link rel="stylesheet" href="{css_url}">\n')
    ht += H(t'</head>\n')
    return ht.getvalue()