0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Jinja2 だけでは物足りなくて作った typed HTML ビルダ「ZenHtml」

Last updated at Posted at 2025-11-24

2025-11-25 新規リリースRelease

動機

個人で内製システムをフルスタック開発しています。以前は 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",
    )

HtmlDocumenthead/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 を受け付けるようにしました
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?