htmlppという式指向のhtml用のプリプロセッサーのようなものを作っている。

最近、なんとなく htmlpp というものを作っている。 似非custom elementは式ベースのマクロである程度まではいけるのでは?ということの実験のために作っている感じ。 直接htmlファイルに対して使うというよりは、テンプレートエンジン(e.g. jinja2, mako)の入力として使われるようなファイルに対しての利用を想定している。

構想

以下のようなhtmlがあるとする。.boxという外装を持ったタグが内容物としてmessageという文字列を持つというもの。 実際には、ここの.box部分のtagがもう少し複雑であったりする場合などがある。

<div class="box">
 <p>message</p>
</div>

これを通常のテキストベースのテンプレートエンジン(jinja2, makoなど)では、以下の様にして内容物の部分をパラメタライズする。 (最も、makoについては幾分か例外が存在するのでjinja2やdjango templateを念頭に入れて考えることにする)

# jinja2
<div class="box">
 <p>{{ message }}</p>
</div>

ここでこれ以上のコンポーネント化は進めることができなそう(後でくわしく)。

一方でこのような文字列を生成する関数を考えてみる。 やりたいことは以下の2つを分けて定義しながら再利用したい

  • 外装部分
  • 内容部分

内容部分を後で埋めるということから以下のような関数を書いてみる。

@contextlib.contextmanager
def box():
    print('<div class="box">')
    yield
    print('</div>')

このように定義しておくと、後で内容部分を変えたい場合には以下の様にして使える。

with box():
    print("<p>message</p>")

# output
#<div class="box">
#<p>message</p>
#</div>

別の見方をすると、htmlのドキュメントを作成するのにvirtual dom的な関数を使うとする。これは以下のような形で使われる。

// mithril.jsから拝借
m("div.box", [m("p", "message")])

// ココでboxと同様の抽象を作るには以下の様な関数を定義するだけ

function box(content){
  return m("div.box", [content]);
}

box(m("p", "message")); // <div class="box"><p>message</p></div>

これと同様の機構をテンプレートエンジンに組み込むことができないかというのが元々の発想。

似非custom element = プリプロセッサーによる展開

結局のところ、外装部分(タグ)を事前に定義しておく、そして利用時にタグの内部に内容物(ブロック)を書くという形にしておけば、 後に文字列置換で該当するタグ部分を定義した構造に変換するにすぎないわけで、プリプロセッサーのようなものを書いてあげれば済むのではないかと思い始めた。

<@def name="box">
<div class="box">
<@yield/>
</div>
</@def>

<@box><p>message</p></@box>
 <!-- @box部分が div.box のタグに変換される -->
 -> <div class="box"><p>message</p></div>

さて、ここまでは実はmakoでもcaller.body() を使うことでできる。一方でこのような構造のものはどうだろう。

<div class="box">
  <div class="box-title"><!-- ここにtitleを埋め込みたい --></div>
  <div class="box-content"><!-- ここにcontentを埋め込みたい --></div>
</div>

ここでxamlのフォーマットを借りてくる。 通常あるタグの内部の要素として書かれたものが、タグ中のどの属性に対応するものかを利用時に指定する必要がなくなっている。 xamlにおいてはContentPropertyAttributeが指定されたものが、何も指定していない場合の部分として使われる。

例えば、以下のようにタグがBoxが使われていたとして

<Box>
  <p>message</p>
</Box>

これは、以下と同様のものとして解釈される。(ContentPropertyAttribute=Body)

<Box>
<Box.body>
  <p>message</p>
</Box.body>
</Box>

これの何が嬉しいかと言うと、引数として渡そうとする時に文字列であってもタグで構成されたドキュメントであっても扱えるということ

<Box title="box of title">
<Box.body>
  <p>message</p>
</Box.body>
</Box>

<!-- ここでtitleを<h4>要素で包みたい -->
<Box>
<Box.title>
<h4>box of title</h4>
</Box.title>
<Box.body>
  <p>message</p>
</Box.body>
</Box>

それと同様のことを考えて、以下のようにyieldにname引数で渡せることにする。 無引数のyieldはname引数にbodyを渡したものの省略形であるということにする。

<!-- 元々欲しかった形式
<div class="box">
  <div class="box-title"><!-- ここにtitleを埋め込みたい --></div>
  <div class="box-content"><!-- ここにcontentを埋め込みたい --></div>
</div>
-->

<@def name="box">
  <div class="box-title"><@yield name="title"/></div>
  <div class="box-content"><@yield/></div>
</@def>

<!-- 使う時 -->
<@box>
  <@box.title><h4>title of box</h4></@box.title>
  <@box.body><p>message</p></@box.body>
</@box>

ただし、定義した似非customタグは属性を保持したいので、属性名とbox.を同一視するということは止め。 :で始まる属性名をブロックとして扱うことにした。そういうわけで以下の様な略式の書き方も可能。

<@box :title="title of box">
  <p>message</p>
</@box>

<!-- 完全に明示的に書くと以下と同義 -->
<@box>
  <@box.title>title</@box.title>
  <@box.body><p>message</p></@box.body>
</@box>

これで色々機能は不足しているものの、修飾的な要素をタグ側に書き、ドキュメントの構造をタグを利用して書くということができるようになったのではないかと思う。例えばテキトーに自身のタグを定義しておけば、以下の様な感じに見た目をさっぱり綺麗な感じにできるかもしれない。

<@page>
  <@card>
    <@subject>hello</@subject>
    <@content>long time no see, blah blah blah...</@content>
  </@card>
  <@card>
    <@subject>bye</@subject>
    <@content>...</@content>
  </@card>
</@page>

また permissionにより aタグにしたり単なるdiv要素にしたりなどというのも

<@def name="a">
  {% if <@yield name="condition"/> %}
    <a><@yield/></a>
  {%else}
    <div><@yield/></div>
  {%endif>
</@def>

<!-- 以下の様に使える -->
<@a :condition="{{is_admin}}" href="/write"><i class="fa-pencil">write</i></@a>

定義した似非custome elementに渡した属性の意味

ひとつ前のところで定義した自分勝手な似非custom elementである@aについて、似非であってもcustom elementであるならば属性を渡したいと思うのは自然なところ。 先ほどの例ではhref属性を渡しているが、これがどのように扱われるかというと、ちょっと仕様としては綺麗ではないものの、今のところ以下の様になっている。

defで定義したブロック内における、最も最初に見つかったhtmlタグっぽい部分に対する属性だと判断する。

なので例えばこのようになる。

<@def name="A"><div class="a">A</div></@def>
<@def name="B"><div class="b">B</div><div class="b">B2</div></@def>

<@def name="C">
{% if xxxx %}
<div class="c">true</div>
{% else %}
<div class="c">false</div>
{% endif %}
</@def>

<@def name="D">
{% if not xxxx %}
<div class="d">false</div>
{% else %}
<div class="d">true</div>
{% endif %}
</@def>

それぞれ、以下の様に変換される。

<@A id="mine"/>
  -> <div class="a" id="mine">A</div>

<@B id="mine"/>
  -> <div class="b" id="mine">B</div><div class="b">B2</div>

<@C id="mine"/>
  ->
  {% if xxxx %}
  <div id="mine" class="c">true</div>
  {% else %}
  <div class="c">false</div>
  {% endif %}

<@D id="mine"/>
  ->
  {% if not xxxx %}
  <div class="d" id="mine">false</div>
  {% else %}
  <div class="d">true</div>
  {% endif %}

また特にclass属性などは利用する側で一部書き換えたいことがあるだろうということで以下のような特殊な仕様を追加してみている。 デフォルトでは上書きになり、:add,:delを加えた属性により追加・削除ができる

<@def name="box"><div class="box"><@yield/></div><@def>

<@box class="hmm">content</@box>
 -> <div class="hmm">content</div>

<@box class:add="hmm">content</@box>
 -> <div class="box hmm">content</div>

<@box class:del="box">content</@box>
 -> <div class="">content</div>

外部モジュールのインポート

もちろん、通常のpythonコードのようにモジュールのインポートはできて欲しい。 @importというタグが使える。これはaliasで別名を定義する事ができる。 また、インポートしたモジュールを利用するにはモジュール名 + ":" を前置する。

<@import module="helper" alias="h"/>

</@h:box>hmm</@h:box>

ちなみにこの場合ではhelper.pre.htmlという名前のファイルが有ることを前提にしている。

また、この形式でのインポートの他に@pyimportというものがある。 これはpython package内のモジュールをインポートする機能を持っている。

<@pyimport module="htmlpp.utils" alias="u"/>

<@u:hello/>

ここではhtmlpp.utilsがpython側でインポートできることを前提にしている。

使う

例えば以下のような定義がある時に、 htmlpp render を使うことでプリプロセッサ〜を通した結果を得る事ができる。

box.pre.html

<@def name="box">
<div class="box"><@yield/></div>
</@def>

main.pre.html

<@import module="box" alias="b"/>
<@b:box>hello</@b:box>
# htmlpp render --directory=. main.pre.html の省略形
$ htmlpp render main.pre.html

<div class="box">hello</div>

--directory で.pre.htmlを探索する場所を変更できるが、まだ細かい調整は出来ていない。

おまけ .pre.html -> .pyの変換

.pre.htmlから.pyへの変換は必要ないがあると便利かもしれないので変換機能も作った。 (内部的にはこれと同様のことをランタイム時に行っている)

box.pre.html

<@def name="box">
<div class="box"><@yield/></div>
</@def>

htmlpp のcodegenを使うことで.py形式に変換できる

htmlpp codegen box.pre.html > box.py

以下のような出力が得られるが。通常python側からまともに使えるものではない。 (ただし、python package内に入れることで@pyimportから使う事ができるようにはなる)

import pickle
from collections import OrderedDict
from htmlpp.utils import string_from_attrs, merge_dict
from htmlpp.codegen import render_with


def render_box(_writer, _context, _kwargs, _default_attributes=pickle.loads(b'\x80\x03ccollections\nOrderedDict\nq\x00]q\x01]q\x02(X\x05\x00\x00\x00classq\x03X\x05\x00\x00\x00"box"q\x04ea\x85q\x05Rq\x06.')):

    # _default_attributes :: OrderedDict([('class', '"box"')])
    _writer('\n')
    D = OrderedDict()
    merge_dict(D, _default_attributes)
    if '_attributes' in _kwargs:
        merge_dict(D, _kwargs['_attributes'])
    _writer('<div{attrs}>'.format(attrs=string_from_attrs(D)))
    _kwargs["block_body"](_writer, _context)
    _writer('')
    _writer('</div>\n')


def setup(_context):
    pass


def render_(_writer, _context, _kwargs, _default_attributes={}):
    pass


def render(_context, _writer=None):
    setup(_context)
    return render_with(render_, _context, _writer=_writer)

雑に説明すると

  • render_ -- @defで定義したタグがpythonの関数になる
  • render_ -- 実際のbody全体を表している。今回は特に指定が無かったので空
  • setup -- これはモジュールが読み込まれた場合のhook関数(@importなどの結果が格納される)
  • render -- main()のようなもの。ただしimportされる前提のモジュールは利用されないことが多い。

(pickleを使っているのは順序関係を保持したdictの保存がだるかったから)

一応defのネストなどにも対応している