html_compose
html-compose
A library for natural HTML composition directly in Python.
Focused on fast, flexible, extensible document generation, its goal is to make the web platform fun to work with while using modern browser technologies.
Quick Start
All HTML elements from the living spec are available to use with full type hinting:
from html_compose import a
element = a(href="/logout")["Log out"]
print(element.render())
# <a href="/logout">Log out</a>
The [] syntax provides a natural way to define child elements, making the code
resemble the HTML structure it represents.
Behind the scenes, this is .base_element.BaseElement.append, which accepts text,
elements, lists, nested lists, and callables. It returns self for chaining.
Think of it as:
()sets attributes[]adds children
Set non-constructor attributes with a dict:
a({"@click": "alert(1)"}, href="#")["Click me"]
Security: The children of HTML elements are always HTML escaped, so XSS directly in the HTML is not possible.
JavaScript within HTML attribute values is always escaped. Just don't pass user input into JavaScript attributes.
Use .unsafe_text() when you need unescaped content.
All HTML nodes treat their children as if they contain HTML.
This means if you have a <script> or <style> element or something
else that isn't read as HTML, you may need to handle escaping yourself before
passing to .unsafe_text().
Imports
You can import elements from this module or html_compose.elements:
from html_compose import a, div, spanfrom html_compose.elements import a, div, spanimport html_compose.elements as el
Building Documents
Use document_generator for complete HTML5 documents with optimized resource management:
from html_compose import p
from html_compose.document import document_generator
from html_compose.resource import js_import, css_import
# Local module with cache-busting
admin_script = js_import(
"./static/admin.js",
name="admin",
cache_bust=True,
preload=True
)
# Remote library
alpine = js_import(
'https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js',
defer=True
)
# CSS with integrity checking and preload
bootstrap_css = css_import(
"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css",
preload=True,
hash="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM",
crossorigin="anonymous"
)
doc: str = document_generator(
title="My App",
css=[bootstrap_css],
js=[admin_script, alpine],
head_extra=[], # anything you want to add to head
body_content=[
p(class_="container")["Hello, world!"]
]
)
Generates HTML with correct <link>, <script>, importmap, and preload tags
in the optimal order.
For streaming responses, use document_streamer to send the <head> early.
Basic Document API
If you prefer simpler boilerplate generation, HTML5Document returns a complete
document string:
from html_compose import HTML5Document, p, script, link, meta
doc: str = HTML5Document(
"Site Title",
lang="en",
js=[
'/public/bundle.js',
],
css=[
'/public/style.css',
],
head_extra=[
meta(name='robots', content='index, follow'),
],
body=[p["Hello, world!"]],
)
See html_compose.resource for advanced resource configuration,
or view html_compose.document for for more on document generation.
Composing Elements
The constructor for an element defines attributes, so if it has none the call
to the constructor can be skipped like the p and strong elements below:
from html_compose import div, strong, a
user = "github wanderer"
content = div(class_="profile")[
p["Welcome, ", strong[user], "!"],
a(href="/logout")["Log out"]
]
print(content.render())
# <div class="profile"><p>Welcome, <strong>github wanderer</strong>!</p><a href="/logout">Log out</a></div>
More Features
Custom Elements
Create custom elements with CustomElement.create or create_element:
from html_compose.custom_element import CustomElement
foo = CustomElement.create("foo")
foo["Hello world"].render() # <foo>Hello world</foo>
# Or use the shorthand
from html_compose import create_element
bar = create_element("bar")
bar()["Hello world"].render() # <bar>Hello world</bar>
Type Hints
All elements and attributes are fully type-hinted for IDE support. Your editor can complete element names and attributes.
Flexible Attributes
Attributes support multiple input formats:
img([img.hint.src("..."), {"@click": "..."}, onmouseleave="..."])
Extensions
Custom attributes for frameworks can be packaged as reusable modules:
from pretend_extensions import htmx
from html_compose import button, div
button([htmx.get('/url'), htmx.target('#result')])
# <button hx-get="/url" hx-target="#result"></button>
Command-line Interface
Convert HTML to html-compose syntax—useful when starting from tutorials or templates:
html-compose convert {filename or empty for stdin}
html-compose convert --noimport el # produces el.div() style references
html-convert # an alias for html-compose convert
html-convert provides access to this tool as shorthand.
Core Ideas
We are going to dive into the technicals and core ideas of the library.
String Iterators and Tree Resolution in HTML Generation
Core Concepts
- Generators are beneficial to performance
- Elements are generated one at a time and immediately consumed by the join operation. You never store the complete set in memory.
- Each element is processed exactly once as it flows through the generator pipeline.
- Lazy evaluation: The HTML is generated on-demand, which is particularly valuable when building conditional elements or working with large datasets. -
- String Iterators:
- Used as the output of the
__html__method, which represents sanitized elements and markup which is implemented asdeferred_resolvein our base element class,
- Tree Resolution: The process of walking through the HTML element tree and resolving all children.
Concept: Iterator flattening
An iterator can contain iterators, like this:
ul[ (li["one"], li["two"]) ]
This allows syntax like this:
def get_items(db):
return (
li[
h[row.name],
h2[row.type]
p[row.value]
] for row in db.query.stuff("select name, type, value ...")
)
ul[ get_items(db_session) ]
Concept: Tree Resolution
To resolve element.children into a list of strings, we first have to recursively walk the html element tree and yield all resolved children
We want to resolve the following in two ways.
def get_article_content(_id):
return "my article content"
article[
h1["My cool article"],
p(id="article-1")[
lambda p: database.get_article_content(p.id)
]
]
Deferred resolve
The deferred resolve step will resolve an iterator that looks a lot like this, as a list:
[
"<article>",
"<h1>", "My cool article", "</h1>",
"<p id='article-1'>",
lambda: database.get_article_content()
"</p>",
"</article>"
]
As you can see, the static content that resolves is returned, but callables are returned as themselves.
Full resolution
We simply walk the iterator generated in the deferred resolve step and call any callables before returning to str.join.
We believe a few cool things can be done with this regarding content generation and rendering.
The snippet above turns into
"<p>",
"my article content"
"</p>",
Basic HTML iterator
The deferred_resolve method in our BaseElement class is a generator that yields HTML strings is similar to the following:
def deferred_resolve(self):
attrs = self.resolve_attrs()
children = None if self.is_void_element else [child for child in self.resolve_tree()]
# Yield opening tag, children, and closing tag
yield f"<{self.name} {attrs}>"
if children:
yield from children
yield f"</{self.name}>"
Base Element
The base element has tricks built into it so that you can write HTML faster.
Concepts
[] Syntax for Children
[] syntax is a wrapper for Element.append i.e. div().append, except that it returns itself so that it can be chained: div[ div["a", "b", "c"] ]
Under the hood, this is just BaseElement.__getitem__
[] Syntax Constructor
Normally you would construct an element like h1()["My header"].
You can also do h1["My header"].
You will notice this bypasses running the constructor.
Under the hood, this is just BaseElement.__class_getitem__ which just runs the constructor with no parameters.
This shorthand can save a few keystrokes.
Iterators / Callables
You can nest iterators and even place callables in your children. i.e. div()[lambda: "evaluated at render time", [ br(), "text" ]]
Lambda Parameters
multi-parameter lambda function: div()[lambda node, parent_if_avail: "Demo"]
- 0 params: nothing
- param 1: parent node, if applicable * param 2: its parent, if applicable
Element attributes aren't currently intended to be accessed to prevent mistreating HTML as data or application state, so these parameters likely be more useful in a custom extension of an element.
Attribute Classes
You can access Element.attribute i.e. img.srcset() with description, implemented as classes which are chldren of subclass.
These can be passed in element initialization a(attrs=[a.href("https://google.com")]) and has the benefit of auto-complete.
LRU Cache
- The basic attribute concatenation functions are called a lot and so they maintain an LRU cache configurable in size via
Element.ATTR_CACHE_SIZEi.e.div.ATTR_CACHE_SIZE. This works because it can guarantee it is only working on strings. - The multi-parameter lambda function also has an LRU cache to reduce time spent getting function parameters.
Name Conflicts
Examples:
class_del_
You can't use class as an argument in Python because it is a keyword. We opt to call it class_
This alternative was chosen because it is identical to autocomplete.
The same rule is applied anywhere else a name conflicts with a keyword i.e. the del element.
Repeat Attributes
In the event class or style occur multiple times, they are concatenated with the correct delimiter in the order they're received.
Because there's no clear way to concat other attributes, an exception is raised.
XSS Prevention / Automatic Escape
Unescaped child nodes i.e. strings are automatically escaped to prevent XSS.
This is done by the PalletsProjects markupsafe library.
Code Generator
Idea: What if your editor had built-in hinting around HTML properties?
Implementation
We take information from the HTML spec living document and MDN.
We place generated code in a separated directory, keeping magic out of the code.
the tools directory contains:
- The spec generator
spec_generator.py- Pull, parse, and dump json we think is interesting
- The attribute code generator
generate_attributes.py- Put that interesting information in generated class attributes using our
formula defined in previous specs. If an attribute is a keyword or
restricted by Python, append
_to the end of the name so autocomplete works. Dump those insrc/html_compose/attributes
- Put that interesting information in generated class attributes using our
formula defined in previous specs. If an attribute is a keyword or
restricted by Python, append
The element code generator
generate_elements.py- Same deal as for attributes. Dump in src/html_compose/elements.
tools/generated/*.pyis the intermediate directory so runs do not write to the source control directory, but you can see when a new change has happened.
Attributes
The goal of the library is to enable the user to make design choices about how they generate their HTML.
Therefore, the library proposes several ways to define attributes for an HTML element.
The theory is that creating the least resistance to a successful pattern makes way for its adoption.
from html_compose import div
is_error = False
# keyword arg syntax (preferred)
# note that attributes that conflict with Python keywords
# have an underscore_ appended. This was chosen so autocomplete still works.
div(class_="flex")
div(class_=["flex", "items-center"])
div(class_={
"flex": True,
"error": is_error
})
div([div.hint.class_("flex")])
div([div._.class_("flex")])
# div._ is a syntax shorthand for div.hint
# attrs dict syntax
div(attrs={"class": "flex"})
div(attrs={"class": ["flex"]})
div(attrs={"class": {
"flex": True,
"error": is_error
}})
# Also technically works
div(attrs={"class": div.hint.class_("flex")})
# attrs list syntax
div(attrs=[div.hint.class_("flex")])
div(attrs=[div.hint.class_(["flex", "items-center"])])
div(attrs=[div.hint.class_({
"flex": True,
"error": is_error == True
})])
# Combining the two:
div(attrs=[div.hint.class_("flex")], tabindex=1)
BaseAttribute
All attributes inherit from BaseAttribute, which defines a key and a value and resolves at render time.
The class and style attributes have special rules to join values with their correct delimiter.
from html_compose import div
is_red = False
# dict of str:bool - if the value is true, the key is rendered as part of the class list
# truthy = rendered
# falsey = ignored
div.hint.class_({
'red': is_red,
'blue': not is_red
}
)
# "blue"
# list of values (joined by whitespace)
div.hint.class_(["red", "center"])
# "red center"
div._.class_("red")
# "red"
An easy mistake is getting caught assuming the dictionary will resolve the value
from html_compose import div
# This is NOT the correct way to use a dictionary
div.hint.class_({
'color': "red", # ❌ Incorrect use
}
)
# "color" ❌ is likely not what you wanted
An exception to the rule is style
from html_compose import div
# the style attribute has special handling.
div.hint.style({
'background': "red", # OK
"flow-direction": "row"
}
)
"background: red; flow-directionn: row"
The implementation is the simplest `
attrs= parameter syntax
In the constructor for any element, you can specify the attrs parameter.
It can be either a list or a dictionary.
Implicit/positional attrs argument
Although the documentation is explicit in using the attrs kwarg, attrs is
actually the first argument of the constructor and its name can be omitted.
div({"class": "flex"})
Instead of
div(attrs={"class": "flex"})
list
It supports a list of BaseAttributes but also you can mix a dictionary in as well.
from html_compose.elements import a, div
div(attrs=[div.class_("red")])
a(attrs=[
a.hint.href("https://google.com"),
a.hint.tabindex(1),
a.hint.class_(["flex", "flex-col"])
])
a(attrs=[
{"@custom": "value"},
a.hint.href("https://google.com"),
a.hint.tabindex(1),
a.hint.class_(["flex", "flex-col"])
])
# string / list of string is explicitly NOT supported
# it requires disabling sanitization and is therefore quietly prone to XSS
div(attrs=['class="red"']) # ❌
dict
a(attrs={
"href": "https://google.com"),
"tabindex": 1
})
div(attrs={
"class": "red"
})
div(attrs={
"class": ["flex", "items-center"]
})
Keyword argument extension
An extension of the attrs syntax was generated for all built-in HTML elements. It would be time-consuming to do this for custom element types, but code generation lends itself well to this case.
Traditionally, kwargs would be too non-descript to provide helpful editor hints.
To aid with fluent document writing, each element was generated with its attributes as parameters and a paired docstring.
i.e.
:param href: Address of the hyperlink
a(href="https://google.com", tabindex=1)
Under the hood, it's all translated to the BaseAttribute class, and the value is
escaped before rendering.
Breakdown
There are a number of options for declaring an attribute value, which are shown above. The basic idea is
attrs, the first parameter, is a key,value attribute set, or a list
containing one or more of
- a
dictthat translateskey="{safe_text(value)}", as if attrs were a dict BaseAttributewhich may be from a hint class for an element or library
Attribute definitions
Care was put into generating attribute definitions for each class.
Anything found in the HTML specification document is available in an element's cousin attribute class.
i.e. the img class has a cousin class ImgAttrs.
We can access the definition of an attribute for that element via ImgAttrs.$attr i.e. ImgAttrs.alt(value="demo"). Each element, like img, has a child class hint which inherits from its sibling attrs class (ImgAttrs), so you can access the same definition via img.hint.alt("...").
Additionally, there's a _ shorthand for img.hint. img._ is just a reference to img.hint.
The purpose of this system is to provide full type hints.
It also serves as an example for extensions to add attribute sets under their own namespaces/classes.
Extensions
Quality extensions are recommended to work with your chosen tech stack. The idea is to give you guardrails and documentation directly in your IDE.
from html_compose.base_attribute import BaseAttribute
from html_compose import button
class htmx:
'''
Attributes for the HTMX framework.
'''
@staticmethod
def get(value: str) -> BaseAttribute:
'''
htmx attribute: hx-get
The hx-get attribute will cause an element to issue a
GET to the specified URL and swap the HTML into the DOM
using a swap strategy
:param value: URI to GET when the element is activated
:return: An hx-get attribute to be added to your element
'''
return BaseAttribute("hx-get", value)
Where we can write
button(
[htmx.get("/api/data")],
class_="btn primary"
)["Click me!"]
Live Reload
If it takes multiple steps to make a change, I'm going to make a change slowly.
If I have to do nothing to notice my change immediately, I'm going to make changes quickly.
The idea is to iterate rapidly, so we provide a generic tool to help you do just that.
Live reload is an optional feature of html-compose to aid in rapid development.
Browser based livereload is provided by livereload-js.
To trigger it, we host a websocket server on port 51353 by default.
Our library comes optionally equipped with a file watcher that derives certain actions from file events.
It can be used to run your webserver and build commands in certain events.
Path expressions
Paths expressions essentially globs as you understand them from glob.glob
with recursive=True.
Ignore_glob and path_glob use the same mechanism.
Regular glob
They support regular file globbing where * is 1 or more characters i.e. *.py
Recursive glob
They support recursion via ** which matches any 0 or more directories:
src/**/*.py will match both src/demo.py and src/my/nested/dir/demo.py.
Trailing / (recursive)
A trailing / will also be interpreted as a recursive match i.e.
src/ will match src/any/file.txt
Example usage
This demonstrates a Flask application uses a combination of vanilla js and bundled node dependencies.
live.py
import html_compose.live as live
live.server(
daemon=live.ShellCommand(
"flask --app ./src/web_demo/server.py run"
),
daemon_delay=1,
# These conditions determine when to reload the flask app
# And commands to run based on the matching condition
conds=[
live.WatchCond(
# Trigger reload when a python file changes
path_glob="src/**/*.py",
ignore_glob="src/.venv/"
action=None,
),
live.WatchCond(
"node-app/**/*.js",
action=live.ShellCommand("./build.sh"),
# no reload means not to try to reload the daemon or browser
reload=False,
),
live.WatchCond(
# Trigger reload when the bro
"public/**/*.js",
action=None,
),
],
host="localhost",
port=51353,
)
build.sh
#!/usr/bin/env bash
(cd node-app && (
./node_modules/.bin/esbuild ./myapp.js --bundle --outfile=../public/node-app.js
)
)
running:
[~/src/mine/web-demo]$ python3 livereload.py
Monitoring for changes: src
Monitoring for changes: node-app
Monitoring for changes: public
Monitoring 3 path(s) via RustNotify. 3 path(s) are monitored recursively.
Starting livereload WebSocket server at ws://localhost:51353
* Serving Flask app './src/web_demo/server.py'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
Changed: node-app/myapp.js
../public/node-app.js 316.6kb
⚡ Done in 16ms
Changed: public/node-app.js
Reloading daemon after 1.0 seconds...
* Serving Flask app './src/web_demo/server.py'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
TLS
Live reload is a development feature and not recommended for production.
However there are some instances in which you can only debug behind a TLS server.
To prevent mixed content errors, livereload auto-detects the protocol from the URI. However, we do not ship settings for configuring TLS.
In order to make TLS work, serve the websocket behind a reverse proxy.
If you're using nginx, you would include something like this in your server block:
location /ws/ {
proxy_pass http://localhost:51353;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
Since you're serving your websocket over SSL, you can now specify when calling live.server:
proxy_host: host to reach for the livereload websocket. used in browser instead ofhostparameter.
Example: `my-sweet-website.com`
proxy_uri: URI
Example: `/ws/`
Resource imports (js / css / fonts)
There are now many standards in the web platform for correctly importing your remote resources.
We no longer need to use nodejs and bundling to use module import semantics.
We can preload js and assign it a module name for importing.
We give helpers for managing css/js imports and the many attributes needed to successfully preload and validate resource integrity.
We also give two font helpers, one for fonts resolved in css and one for manually setting up .woff/etc font imports.
Browser tech overview
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/modulepreload
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload
How our library makes resource setup better
By providing small helpers, we reduce the amount of redundant html to write.
import html_compose.elements as el
from html_compose.resource import css_import, js_import, to_elements
from html_compose.document import document_generator
def get_css():
return [
css_import("./static/admin.css", cache_bust=False),
css_import(
"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css",
hash=(
"sha384-9ndCyUaIbzAi2FUVXJi0CjmCapS"
"mO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
),
crossorigin="anonymous",
preload=True,
),
]
def get_js():
return [
js_import("./static/admin.js", name="admin", cache_bust=True),
js_import(
"https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm",
name="alpinejs",
preload=True,
hash=(
"sha384-Yf57wlxlrA1+0X6Ye9NOBxQ1tpmiwI/"
"9mFpv9tT/Rh2UAajwwAlTWHnvTGYhgv7p"
),
crossorigin="anonymous",
),
]
def get_fonts():
return [
font_import_manual(
"./static/fonts/MyFont.woff2",
family="MyFont",
weight="normal",
style="normal",
display="swap",
cache_bust=False,
),
font_import_provider(
href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap",
preconnect=[
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
preconnect_crossorigin="anonymous",
),
]
def test_importer():
css = get_css()
js = get_js()
elements = to_elements(js, css)
print(el.head()[elements].render())
def test_document_generator():
css = get_css()
js = get_js()
print(document_generator(
title="demo",
lang="en",
js=js,
css=css,
body_content=[el.h1("Hello world")])
test_importer:
<head>
<link
href="https://fonts.googleapis.com"
crossorigin="anonymous"
rel="preconnect"
/>
<link
href="https://fonts.gstatic.com"
crossorigin="anonymous"
rel="preconnect"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
as="style"
rel="preload"
/>
<link
href="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
integrity="sha384-Yf57wlxlrA1+0X6Ye9NOBxQ1tpmiwI/9mFpv9tT/Rh2UAajwwAlTWHnvTGYhgv7p"
crossorigin="anonymous"
rel="modulepreload"
/>
<link
href="./static/fonts/MyFont.woff2"
type="font/woff2"
as="font"
rel="preload"
/>
<link href="./static/admin.css" rel="stylesheet" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<style>
@font-face {
font-family: "MyFont";
src: url("./static/fonts/MyFont.woff2");
font-style: normal;
font-display: swap;
font-weight: normal;
}
</style>
<script type="importmap">
{
"imports": {
"admin": "./static/admin.js?ts=1760157623",
"alpinejs": "https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
}
}
</script>
<script src="./static/admin.js?ts=1760157623" type="module"></script>
<script
src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
type="module"
integrity="sha384-Yf57wlxlrA1+0X6Ye9NOBxQ1tpmiwI/9mFpv9tT/Rh2UAajwwAlTWHnvTGYhgv7p"
crossorigin="anonymous"
></script>
</head>
test_document
<!DOCTYPE html>
<html lang="en">
<head>
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>demo</title>
<link
href="https://fonts.googleapis.com"
crossorigin="anonymous"
rel="preconnect"
/>
<link
href="https://fonts.gstatic.com"
crossorigin="anonymous"
rel="preconnect"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
as="style"
rel="preload"
/>
<link
href="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
integrity="sha384-Yf57wlxlrA1+0X6Ye9NOBxQ1tpmiwI/9mFpv9tT/Rh2UAajwwAlTWHnvTGYhgv7p"
crossorigin="anonymous"
rel="modulepreload"
/>
<link
href="./static/fonts/MyFont.woff2"
type="font/woff2"
as="font"
rel="preload"
/>
<link href="./static/admin.css" rel="stylesheet" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<style>
@font-face {
font-family: "MyFont";
src: url("./static/fonts/MyFont.woff2");
font-style: normal;
font-display: swap;
font-weight: normal;
}
</style>
<script type="importmap">
{
"imports": {
"admin": "./static/admin.js?ts=1760157623",
"alpinejs": "https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
}
}
</script>
<script src="./static/admin.js?ts=1760157623" type="module"></script>
<script
src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/+esm"
type="module"
integrity="sha384-Yf57wlxlrA1+0X6Ye9NOBxQ1tpmiwI/9mFpv9tT/Rh2UAajwwAlTWHnvTGYhgv7p"
crossorigin="anonymous"
></script>
</head>
<body>
<h1>Hello world</h1>
</body>
</html>
Where to go from here
We've informed the browser how to optimally and in parallel load our resources.
Now we are free to develop without bundling javascript if we so desire.
Heck, you could even map a package.json used in your development environment
into a js_import generator.
The sky is the limit.
1""" 2# html-compose 3 4A library for natural HTML composition directly in Python. 5 6Focused on fast, flexible, extensible document generation, 7its goal is to make the web platform fun to work with while using 8modern browser technologies. 9 10## Quick Start 11 12All HTML elements from the [living spec](https://html.spec.whatwg.org/multipage/) 13are available to use with full type hinting: 14 15```python 16from html_compose import a 17 18element = a(href="/logout")["Log out"] 19print(element.render()) 20# <a href="/logout">Log out</a> 21``` 22 23The `[]` syntax provides a natural way to define child elements, making the code 24resemble the HTML structure it represents. 25 26Behind the scenes, this is `.base_element.BaseElement.append`, which accepts text, 27elements, lists, nested lists, and callables. It returns self for chaining. 28 29Think of it as: 30* `()` sets attributes 31* `[]` adds children 32 33Set non-constructor attributes with a dict: 34 35```python 36a({"@click": "alert(1)"}, href="#")["Click me"] 37``` 38 39**Security**: The children of HTML elements are always HTML escaped, 40so XSS directly in the HTML is not possible. 41 42JavaScript within HTML attribute values is always escaped. 43Just don't pass user input into JavaScript attributes. 44 45Use `.unsafe_text()` when you need unescaped content. 46 47All HTML nodes treat their children as if they contain HTML. 48This means if you have a `<script>` or `<style>` element or something 49else that isn't read as HTML, you may need to handle escaping yourself before 50passing to `.unsafe_text()`. 51 52### Imports 53 54You can import elements from this module or `html_compose.elements`: 55 56- `from html_compose import a, div, span` 57- `from html_compose.elements import a, div, span` 58- `import html_compose.elements as el` 59 60### Building Documents 61 62Use `document_generator` for complete HTML5 documents with optimized resource management: 63 64```python 65from html_compose import p 66from html_compose.document import document_generator 67from html_compose.resource import js_import, css_import 68 69# Local module with cache-busting 70admin_script = js_import( 71 "./static/admin.js", 72 name="admin", 73 cache_bust=True, 74 preload=True 75) 76 77# Remote library 78alpine = js_import( 79 'https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js', 80 defer=True 81) 82 83# CSS with integrity checking and preload 84bootstrap_css = css_import( 85 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css", 86 preload=True, 87 hash="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM", 88 crossorigin="anonymous" 89) 90 91doc: str = document_generator( 92 title="My App", 93 css=[bootstrap_css], 94 js=[admin_script, alpine], 95 head_extra=[], # anything you want to add to head 96 body_content=[ 97 p(class_="container")["Hello, world!"] 98 ] 99) 100``` 101 102Generates HTML with correct `<link>`, `<script>`, `importmap`, and preload tags 103in the optimal order. 104 105For streaming responses, use `document_streamer` to send the `<head>` early. 106 107### Basic Document API 108 109If you prefer simpler boilerplate generation, `HTML5Document` returns a complete 110document string: 111 112```python 113from html_compose import HTML5Document, p, script, link, meta 114 115doc: str = HTML5Document( 116 "Site Title", 117 lang="en", 118 js=[ 119 '/public/bundle.js', 120 ], 121 css=[ 122 '/public/style.css', 123 ], 124 head_extra=[ 125 meta(name='robots', content='index, follow'), 126 ], 127 body=[p["Hello, world!"]], 128) 129``` 130See `html_compose.resource` for advanced resource configuration, 131or view `html_compose.document` for for more on document generation. 132 133 134### Composing Elements 135 136The constructor for an element defines attributes, so if it has none the call 137to the constructor can be skipped like the `p` and `strong` elements below: 138 139```python 140from html_compose import div, strong, a 141 142user = "github wanderer" 143content = div(class_="profile")[ 144 p["Welcome, ", strong[user], "!"], 145 a(href="/logout")["Log out"] 146] 147 148print(content.render()) 149# <div class="profile"><p>Welcome, <strong>github wanderer</strong>!</p><a href="/logout">Log out</a></div> 150``` 151 152## More Features 153 154### Custom Elements 155 156Create custom elements with `CustomElement.create` or `create_element`: 157 158```python 159from html_compose.custom_element import CustomElement 160 161foo = CustomElement.create("foo") 162foo["Hello world"].render() # <foo>Hello world</foo> 163 164# Or use the shorthand 165from html_compose import create_element 166 167bar = create_element("bar") 168bar()["Hello world"].render() # <bar>Hello world</bar> 169``` 170 171### Type Hints 172 173All elements and attributes are fully type-hinted for IDE support. Your editor 174can complete element names and attributes. 175 176### Flexible Attributes 177 178Attributes support multiple input formats: 179 180```python 181img([img.hint.src("..."), {"@click": "..."}, onmouseleave="..."]) 182``` 183 184### Extensions 185 186Custom attributes for frameworks can be packaged as reusable modules: 187 188```python 189from pretend_extensions import htmx 190from html_compose import button, div 191 192button([htmx.get('/url'), htmx.target('#result')]) 193# <button hx-get="/url" hx-target="#result"></button> 194``` 195 196## Command-line Interface 197 198Convert HTML to html-compose syntax—useful when starting from tutorials or templates: 199 200```sh 201html-compose convert {filename or empty for stdin} 202html-compose convert --noimport el # produces el.div() style references 203html-convert # an alias for html-compose convert 204``` 205 206`html-convert` provides access to this tool as shorthand. 207 208# Core Ideas 209We are going to dive into the technicals and core ideas of the library. 210 211.. include:: ../../doc/ideas/01_iterator.md 212.. include:: ../../doc/ideas/02_base_element.md 213.. include:: ../../doc/ideas/03_code_generator.md 214.. include:: ../../doc/ideas/04_attrs.md 215.. include:: ../../doc/ideas/05_livereload.md 216.. include:: ../../doc/ideas/06_resource_imports.md 217""" 218 219from typing import Any, Generator, Iterable, cast 220 221from markupsafe import Markup, escape 222 223from .base_types import Node 224 225 226def escape_text(value) -> Markup: 227 """ 228 Escape unsafe text to be inserted into HTML 229 230 Optionally casting to string 231 """ 232 if isinstance(value, str): 233 return escape(value) 234 else: 235 return escape(str(value)) 236 237 238def unsafe_text(value: str | Markup) -> Markup: 239 """ 240 Return input string as Markup 241 242 If input is already markup, it needs no further casting 243 """ 244 if isinstance(value, Markup): 245 return value 246 247 return Markup(str(value)) 248 249 250def pretty_print(html_str: str, features="html.parser") -> str: 251 """ 252 Pretty print HTML. 253 DO NOT do this for production since it introduces whitespace and may 254 affect your output. 255 256 :param html_str: HTML string to print 257 :param features: BeautifulSoup tree builder to print with 258 :return: Pretty printed HTML string 259 """ # fmt: skip 260 # Production instances probably don't use this 261 # so we lazy load bs4 262 from bs4 import BeautifulSoup # type: ignore[import-untyped] 263 264 return BeautifulSoup(html_str, features=features).prettify( 265 formatter="html5" 266 ) 267 268 269def doctype(dtype: str = "html"): 270 """ 271 Return doctype tag 272 """ 273 return unsafe_text(f"<!DOCTYPE {dtype}>") 274 275 276# The imports are organized to avoid circular dependencies 277# The import x as x pattern is interpreted by some tools as "public export" 278 279# ruff: noqa: E402 280 281# Library primitives 282from .base_attribute import BaseAttribute as BaseAttribute 283from .base_element import BaseElement as BaseElement 284from .custom_element import CustomElement as CustomElement 285 286 287def stream(html_content: Iterable[Node] | Node) -> Generator[str, Any, None]: 288 """ 289 Stream one or more HTML elements as a generator of strings 290 291 :param html_content: An iterable of elements that could be rendered as HTML 292 293 :return: Generator of HTML strings 294 """ 295 last = object() 296 297 def generator() -> Generator[str, Any, None]: 298 # We use a dummy element to implement .resolve() 299 dummy = BaseElement(tag="dummy") 300 dummy.append(html_content) 301 302 element_source = dummy.resolve() 303 next(element_source, None) # Skip dummy start tag 304 305 next_item = next(element_source, last) 306 307 while True: 308 item = cast(str, next_item) 309 next_item = next(element_source, last) 310 if next_item is last: 311 # skip dummy end tag 312 break 313 yield cast(str, item) 314 315 return generator() 316 317 318def render(html_content: Iterable[Node] | Node) -> str: 319 """ 320 Render one or more HTML elements into a single string 321 322 :param html_content: An iterable of elements that could be rendered as HTML 323 324 :return: A single HTML string 325 """ 326 return "".join(stream(html_content)) 327 328 329create_element = CustomElement.create 330# Document features 331from .document import HTML5Document as HTML5Document 332from .document import document_generator as document_generator 333from .document import document_streamer as document_streamer 334 335# Elements 336from .elements import a as a 337from .elements import abbr as abbr 338from .elements import address as address 339from .elements import area as area 340from .elements import article as article 341from .elements import aside as aside 342from .elements import audio as audio 343from .elements import b as b 344from .elements import base as base 345from .elements import bdi as bdi 346from .elements import bdo as bdo 347from .elements import blockquote as blockquote 348from .elements import body as body 349from .elements import br as br 350from .elements import button as button 351from .elements import canvas as canvas 352from .elements import caption as caption 353from .elements import cite as cite 354from .elements import code as code 355from .elements import col as col 356from .elements import colgroup as colgroup 357from .elements import data as data 358from .elements import datalist as datalist 359from .elements import dd as dd 360from .elements import del_ as del_ 361from .elements import details as details 362from .elements import dfn as dfn 363from .elements import dialog as dialog 364from .elements import div as div 365from .elements import dl as dl 366from .elements import dt as dt 367from .elements import em as em 368from .elements import embed as embed 369from .elements import fieldset as fieldset 370from .elements import figcaption as figcaption 371from .elements import figure as figure 372from .elements import footer as footer 373from .elements import form as form 374from .elements import h1 as h1 375from .elements import h2 as h2 376from .elements import h3 as h3 377from .elements import h4 as h4 378from .elements import h5 as h5 379from .elements import h6 as h6 380from .elements import head as head 381from .elements import header as header 382from .elements import hgroup as hgroup 383from .elements import hr as hr 384from .elements import html as html 385from .elements import i as i 386from .elements import iframe as iframe 387from .elements import img as img 388from .elements import input as input 389from .elements import ins as ins 390from .elements import kbd as kbd 391from .elements import label as label 392from .elements import legend as legend 393from .elements import li as li 394from .elements import link as link 395from .elements import main as main 396from .elements import map as map 397from .elements import mark as mark 398from .elements import menu as menu 399from .elements import meta as meta 400from .elements import meter as meter 401from .elements import nav as nav 402from .elements import noscript as noscript 403from .elements import object as object 404from .elements import ol as ol 405from .elements import optgroup as optgroup 406from .elements import option as option 407from .elements import output as output 408from .elements import p as p 409from .elements import picture as picture 410from .elements import pre as pre 411from .elements import progress as progress 412from .elements import q as q 413from .elements import rp as rp 414from .elements import rt as rt 415from .elements import ruby as ruby 416from .elements import s as s 417from .elements import samp as samp 418from .elements import script as script 419from .elements import search as search 420from .elements import section as section 421from .elements import select as select 422from .elements import slot as slot 423from .elements import small as small 424from .elements import source as source 425from .elements import span as span 426from .elements import strong as strong 427from .elements import style as style 428from .elements import sub as sub 429from .elements import summary as summary 430from .elements import sup as sup 431from .elements import svg as svg 432from .elements import table as table 433from .elements import tbody as tbody 434from .elements import td as td 435from .elements import template as template 436from .elements import textarea as textarea 437from .elements import tfoot as tfoot 438from .elements import th as th 439from .elements import thead as thead 440from .elements import time as time 441from .elements import title as title 442from .elements import tr as tr 443from .elements import track as track 444from .elements import u as u 445from .elements import ul as ul 446from .elements import var as var 447from .elements import video as video 448from .elements import wbr as wbr 449 450# Resource features 451from .resource import css_import as css_import 452from .resource import font_import_manual as font_import_manual 453from .resource import font_import_provider as font_import_provider 454from .resource import js_import as js_import
227def escape_text(value) -> Markup: 228 """ 229 Escape unsafe text to be inserted into HTML 230 231 Optionally casting to string 232 """ 233 if isinstance(value, str): 234 return escape(value) 235 else: 236 return escape(str(value))
Escape unsafe text to be inserted into HTML
Optionally casting to string
239def unsafe_text(value: str | Markup) -> Markup: 240 """ 241 Return input string as Markup 242 243 If input is already markup, it needs no further casting 244 """ 245 if isinstance(value, Markup): 246 return value 247 248 return Markup(str(value))
Return input string as Markup
If input is already markup, it needs no further casting
251def pretty_print(html_str: str, features="html.parser") -> str: 252 """ 253 Pretty print HTML. 254 DO NOT do this for production since it introduces whitespace and may 255 affect your output. 256 257 :param html_str: HTML string to print 258 :param features: BeautifulSoup tree builder to print with 259 :return: Pretty printed HTML string 260 """ # fmt: skip 261 # Production instances probably don't use this 262 # so we lazy load bs4 263 from bs4 import BeautifulSoup # type: ignore[import-untyped] 264 265 return BeautifulSoup(html_str, features=features).prettify( 266 formatter="html5" 267 )
Pretty print HTML.
DO NOT do this for production since it introduces whitespace and may
affect your output.
Parameters
- html_str: HTML string to print
- features: BeautifulSoup tree builder to print with
Returns
Pretty printed HTML string
270def doctype(dtype: str = "html"): 271 """ 272 Return doctype tag 273 """ 274 return unsafe_text(f"<!DOCTYPE {dtype}>")
Return doctype tag
288def stream(html_content: Iterable[Node] | Node) -> Generator[str, Any, None]: 289 """ 290 Stream one or more HTML elements as a generator of strings 291 292 :param html_content: An iterable of elements that could be rendered as HTML 293 294 :return: Generator of HTML strings 295 """ 296 last = object() 297 298 def generator() -> Generator[str, Any, None]: 299 # We use a dummy element to implement .resolve() 300 dummy = BaseElement(tag="dummy") 301 dummy.append(html_content) 302 303 element_source = dummy.resolve() 304 next(element_source, None) # Skip dummy start tag 305 306 next_item = next(element_source, last) 307 308 while True: 309 item = cast(str, next_item) 310 next_item = next(element_source, last) 311 if next_item is last: 312 # skip dummy end tag 313 break 314 yield cast(str, item) 315 316 return generator()
Stream one or more HTML elements as a generator of strings
Parameters
- html_content: An iterable of elements that could be rendered as HTML
Returns
Generator of HTML strings
319def render(html_content: Iterable[Node] | Node) -> str: 320 """ 321 Render one or more HTML elements into a single string 322 323 :param html_content: An iterable of elements that could be rendered as HTML 324 325 :return: A single HTML string 326 """ 327 return "".join(stream(html_content))
Render one or more HTML elements into a single string
Parameters
- html_content: An iterable of elements that could be rendered as HTML
Returns
A single HTML string
70 @staticmethod 71 def create(tag: str, void_element: bool = False) -> type["CustomElement"]: 72 """ 73 Create a new element class with the given tag and void_element flag. 74 This method is a factory for creating new element classes. 75 """ 76 return type( 77 safe_name(tag), 78 (CustomElement,), 79 {"tag": tag, "is_void": void_element}, 80 )
Create a new element class with the given tag and void_element flag. This method is a factory for creating new element classes.