動機
個人で内製システムをフルスタック開発しています。以前は React を使っていましたが、実際に活用している機能はごく一部で、当時は開発環境を整えるだけでも重く感じていました。そこで SPA をやめて Jinja2 + サーバーサイドレンダリングへ戻したところ、テンプレートが画面ごとにばらけて再利用しづらい、テンプレート構文エラーが実行時に一気に出て原因を追いにくい、という点が気になるようになりました。
「Python のコードだけで DOM を組めて、型チェックで属性ミスも早めに潰せるものがあれば」と思い、ZenHtml を作りました。
ZenHtml とは
Python から HTML を組むための小さなビルダです。タグ仕様を _tag_spec.py にまとめ、_generator.py が型付きのタグ API (zen_html.h) を自動生成します。依存は標準ライブラリのみです。
- タグごとの属性が
Literalや bool で型付けされており、H.button(type="submit")のように書いたときに mypy や Pyright がタイポを検出してくれます。 - 子要素には Iterable をそのまま渡せるので、
H.div(items)のようにリストやジェネレータを素直に差し込めます。 - 出力は
html_(1 行)、pretty_html()(整形済み)、to_token()(ストリーミング)、dict_(JSON 化しやすい)の 4 つを用意しました。 -
dataset={"userId": 1}→data-user-id="1",style={"font_size": "14px"}→font-size: 14px;のように辞書から属性へ展開する補助機能があります。 - 文字列は自動で HTML エスケープされ、void タグに子要素を渡すなどのミスは
H.strict_validation = True(既定)で即検出されます。生 HTML を差し込みたい場合はH.raw()を使います。
インストール
pip install zenhtml
使い方
from zen_html.h import H
from typing import Literal
def button(kind: Literal["button", "submit"]) -> H:
return H.button("Send", type=kind)
page = H.html(
H.head(
H.meta(charset="utf-8"),
H.title("Hello, ZenHtml"),
),
H.body(
H.h1("Hello"),
H.p("Python だけで HTML を生成する例", class_="lead"),
button("submit"),
),
lang="ja",
)
print(page.pretty_html())
dict_ プロパティを JSON 化してクライアント側のレンダラ(examples/h_render.js)で描画する例や、FastAPI/Starlette でレスポンスとして返す例は examples/ ディレクトリに入っています。タグ仕様を変更したら _generator.py を実行することで zen_html/h.py が再生成される仕組みです。
出力例
snippet = H.div(
H.span("Hi", class_="greeting"),
dataset={"userId": 1},
style={"font_size": "14px"},
)
print(snippet.pretty_html())
print(snippet.dict_)
<div data-user-id="1" style="font-size: 14px;">
<span class="greeting">Hi</span>
</div>
{'tag': 'div', 'props': {'data-user-id': '1', 'style': 'font-size: 14px;'}, 'children': [{'tag': 'span', 'props': {'class': 'greeting'}, 'children': ['Hi']}]}
FastAPI + StreamingResponse + HtmlDocument で Hello World
to_token() が HTML トークンを逐次吐くジェネレータなので、Starlette/FastAPI の StreamingResponse と相性がいいです。examples/sample.py には HtmlDocument ヘルパがあり、to_token() の生成結果に <!DOCTYPE html> を 1 つ足すだけでフルドキュメントをストリーミングできます。
from typing import Iterable, Sequence
from fastapi import FastAPI
from starlette.datastructures import URL
from starlette.responses import StreamingResponse
from zen_html.h import H
app = FastAPI()
def HtmlDocument(
*,
title: str,
description: str = "",
keywords: str = "",
head: Sequence[H] | None = None,
body: Sequence[H] | None = None,
css: Sequence[str | URL] | None = None,
script: Sequence[str | URL] | None = None,
lang: str = "ja",
) -> H:
head_nodes = list(head or ())
body_nodes = list(body or ())
css_nodes = [str(i) for i in css or ()]
script_nodes = [str(i) for i in script or ()]
return H.html(
H.head(
H.meta(charset="utf-8"),
H.meta(name="viewport", content="width=device-width, initial-scale=1"),
H.meta(name="description", content=description),
H.meta(name="keywords", content=keywords),
H.meta(httpEquiv="Pragma", content="no-cache"),
H.meta(httpEquiv="Cache-Control", content="no-store"),
H.title(title),
*(H.link(href=href, rel="stylesheet") for href in css_nodes),
*(H.script(src=src) for src in script_nodes),
*head_nodes,
),
H.body(*body_nodes),
lang=lang,
)
def render(node: H, *, include_doctype: bool = False) -> Iterable[str]:
def iterator() -> Iterable[str]:
if include_doctype:
yield "<!DOCTYPE html>"
yield from node.to_token()
return iterator()
@app.get("/")
def hello():
doc = HtmlDocument(
title="Hello, ZenHtml",
body=(
H.h1("Hello"),
H.p("FastAPI + ZenHtml のサンプル"),
),
)
return StreamingResponse(
render(doc, include_doctype=True),
media_type="text/html; charset=utf-8",
)
HtmlDocument の head/body/css/script 引数に H ノードや文字列を渡すだけで Python の関数をそのままコンポーネント化でき、メタタグや外部リソースの読み込みもまとめて記述できます。
どんなときに便利か
- テンプレートファイルを増やさず、Python コード内で完結させたい画面を作るとき
- mypy や Pyright で属性ミスを早めに検出したいフォームやテーブル
- JSON 経由で DOM 構造を渡し、クライアント側でレンダリングしたい API
React ほどのフレームワークは不要だけれど Jinja2 だけでは型の安心感が足りない、という場面で選択肢になると思います。
サンプルコード
-
examples/sample.py:HResponse/HDocumentResponseを使った FastAPI/Starlette でのレスポンス例 -
examples/pandas_pivot.py: pandas のピボットテーブルをH.tableに落とし込むスクリプト(python -m examples.pandas_pivotで実行) -
examples/h_render.js:dict_を受け取ってブラウザ側で DOM を再構築するヘルパ
ソースコードと README: https://github.com/MeiRakuPapa/ZenHtml
更新記録
2025-11-25 v0.1.3
- children=[...] をキーワード引数で書けるようになり、props を先に書くコードでも扱いやすくなりました
- class_ が str だけでなくリストなどの Iterable を受け付けるようにしました