Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e9a7148
wip
keithasaurus Aug 29, 2025
109f25e
wip
keithasaurus Aug 29, 2025
f188f8e
wip
keithasaurus Aug 29, 2025
11740ae
wip
keithasaurus Aug 29, 2025
91bbec8
wip
keithasaurus Aug 29, 2025
1acceae
wip
keithasaurus Aug 29, 2025
3f3cfdd
basic test
keithasaurus Aug 29, 2025
c180e0b
wip
keithasaurus Aug 29, 2025
76c77d6
wip
keithasaurus Aug 29, 2025
9245013
wip
keithasaurus Aug 29, 2025
a400902
wip
keithasaurus Aug 29, 2025
5de9eb7
wip
keithasaurus Aug 29, 2025
4eb5f62
wip
keithasaurus Aug 29, 2025
92be98d
wip
keithasaurus Aug 29, 2025
1c7fd57
wip
keithasaurus Aug 29, 2025
dd79e91
wip
keithasaurus Aug 29, 2025
7d2db3c
wip
keithasaurus Aug 29, 2025
e746226
wip
keithasaurus Aug 30, 2025
8396460
string contains
keithasaurus Aug 30, 2025
22c4326
wip
keithasaurus Aug 30, 2025
d9be239
wip
keithasaurus Aug 30, 2025
492b1f2
wip
keithasaurus Aug 30, 2025
dc2fa19
wip
keithasaurus Aug 30, 2025
421101f
wip
Aug 30, 2025
2ef2273
wip
Aug 30, 2025
86d4efb
type check
keithasaurus Aug 30, 2025
8459c23
wip
keithasaurus Aug 30, 2025
788cf9f
wi
keithasaurus Aug 30, 2025
b24d46f
wip
keithasaurus Aug 31, 2025
34f7bb9
wip
keithasaurus Aug 31, 2025
a434f94
wip
Aug 31, 2025
e902bab
wip
Aug 31, 2025
0ed243d
wip
Aug 31, 2025
3dde698
wip
Aug 31, 2025
f3672a7
wi
Aug 31, 2025
f1f645b
wip
keithasaurus Sep 1, 2025
52a4c99
wip
keithasaurus Sep 9, 2025
d4ed2ee
wip
keithasaurus Oct 18, 2025
bd0d3d2
wip
keithasaurus Oct 18, 2025
a9079d1
wip
keithasaurus Oct 18, 2025
0287d38
wip
keithasaurus Oct 18, 2025
0e860c7
wip
keithasaurus Oct 19, 2025
a63bfef
wip
keithasaurus Oct 22, 2025
09778de
wip
keithasaurus Oct 22, 2025
09b7bcc
wip
keithasaurus Oct 26, 2025
378f88a
wip
keithasaurus Oct 26, 2025
4804bdc
spacing
keithasaurus Oct 26, 2025
0fc3286
punct
keithasaurus Oct 26, 2025
8368d88
wip
keithasaurus Oct 26, 2025
e42db30
wip
keithasaurus Oct 26, 2025
fa5815e
wip
keithasaurus Oct 26, 2025
686fab8
wip
keithasaurus Oct 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
poetry-version: [2.1]
os: [ubuntu-24.04, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
Expand All @@ -26,7 +26,7 @@ jobs:
- name: run bench (pure python)
run: poetry run python -m bench.run
- name: mypyc
run: poetry run mypyc simple_html/utils.py
run: poetry run mypyc simple_html/core.py
- name: run tests
run: poetry run pytest
- name: run bench (compiled)
Expand Down
98 changes: 97 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Why use it?
- clean syntax
- fully-typed
- speed -- faster even than jinja
- speed -- often faster than jinja
- zero dependencies
- escaped by default
- usually renders fewer bytes than templating
Expand All @@ -18,6 +18,7 @@
```python
from simple_html import h1, render


node = h1("Hello World!")

render(node)
Expand All @@ -36,6 +37,7 @@ Here's a fuller-featured example:
```python
from simple_html import render, DOCTYPE_HTML5, html, head, title, body, h1, div, p, br, ul, li


render(
DOCTYPE_HTML5,
html(
Expand Down Expand Up @@ -79,6 +81,7 @@ As you might have noticed, there are several ways to use `Tag`s:
```python
from simple_html import br, div, h1, img, span, render


# raw node renders to empty tag
render(br)
# <br/>
Expand Down Expand Up @@ -106,6 +109,7 @@ escaped by default; `SafeString`s can be used to bypass escaping.
```python
from simple_html import br, p, SafeString, render


node = p("Escaped & stuff",
br,
SafeString("Not escaped & stuff"))
Expand All @@ -121,6 +125,7 @@ that Tag attributes with `None` as the value will only render the attribute name
```python
from simple_html import div, render


node = div({"empty-str-attribute": "",
"key-only-attr": None})

Expand All @@ -133,6 +138,7 @@ String attributes are escaped by default -- both keys and values. You can use `S
```python
from simple_html import div, render, SafeString


render(
div({"<bad>":"</also bad>"})
)
Expand All @@ -149,6 +155,7 @@ You can also use `int`, `float`, and `Decimal` instances for attribute values.
from decimal import Decimal
from simple_html import div, render, SafeString


render(
div({"x": 1, "y": 2.3, "z": Decimal('3.45')})
)
Expand All @@ -161,6 +168,7 @@ You can render inline CSS styles with `render_styles`:
```python
from simple_html import div, render, render_styles


styles = render_styles({"min-width": "25px"})

node = div({"style": styles}, "cool")
Expand All @@ -184,6 +192,7 @@ You can pass many items as a `Tag`'s children using `*args`, lists or generators
from typing import Generator
from simple_html import div, render, Node, br, p


div(
*["neat", br], p("cool")
)
Expand Down Expand Up @@ -214,6 +223,7 @@ For convenience, most common tags are provided, but you can also create your own
```python
from simple_html import Tag, render


custom_elem = Tag("custom-elem")

# works the same as any other tag
Expand All @@ -225,3 +235,89 @@ node = custom_elem(
render(node)
# <custom-elem id="some-custom-elem-id">Wow</custom-elem>
```

### Optimization

#### `prerender`

`prerender` is a very simple function. It just `render`s a `Node` and puts the resulting string inside
a `SafeString` (so its contents won't be escaped again). It's most useful for prerendering at the module level,
which ensures the render operation happens only once. A simple use case might be a website's footer:

```python
from simple_html import SafeString, prerender, footer, div, a, head, body, title, h1, html, render


prerendered_footer: SafeString = prerender(
footer(
div(a({"href": "/about"}, "About Us")),
div(a({"href": "/blog"}, "Blog")),
div(a({"href": "/contact"}, "Contact"))
)
)


def render_page(page_title: str) -> str:
return render(
html(
head(title(page_title)),
body(
h1(page_title),
prerendered_footer # this is extremely fast to render
)
)
)
```
This greatly reduces the amount of work `render` needs to do on the prerendered content when outputting HTML.

#### Caching
You may want to cache rendered content. This is easy to do; the main thing to keep in
mind is you'll likely want to return a `SafeString`. For example, here's how you might cache with `lru_cache`:

```python
from simple_html import prerender, SafeString, h1
from functools import lru_cache


@lru_cache
def greeting(name: str) -> SafeString:
return prerender(
h1(f"Hello, {name}")
)
```

One thing to remember is that not all variants of `Node` are hashable, and thus cannot be passed directly to a function
where the arguments constitute the cache key -- e.g. lists and generators are not hashable, but they can be
valid `Node`s. Another way to use `prerender` in combination with a caching function is to prerender arguments:

```python
from simple_html import prerender, SafeString, h1, div, html, body, head, ul, li
from functools import lru_cache


@lru_cache
def cached_content(children: SafeString) -> SafeString:
return prerender(
div(
h1("This content is cached according to the content of the children"),
children,
# presumably this function would have a lot more elements for it to be worth
# the caching overhead
)
)

def page(words_to_render: list[str]):
return html(
head,
body(
cached_content(
prerender(ul([
li(word) for word in words_to_render
]))
)
)
)
```
Keep in mind that using `prerender` on dynamic content -- not at the module level -- still incurs all the overhead
of `render` each time that content is rendered, so, for this approach to make sense, the prerendered content should
be a small portion of the full content of the `cached_content` function.
Loading
Loading