HTML と Clojure のコードを分離するテンプレートシステム Enlive

cgrand's enlive at master - GitHub

Enlive は html と Clojure のコードを完全に分離できるのが特徴のテンプレートシステムです。つまり、テンプレート元の html はピュアな html であり、コードはピュアなClojureのコードなのです。デザイナーとプログラマーがどちらもHomeで本領を発揮できます(HTMLの変更が容易。HTML中にダミーのテキストを入れられる。マクロも自由自在)。

動的にWebページを生成する方法にはプログラムとHTMLのどちらを中心にするかという違いがあり、それぞれの解説は Rubyist Magazine テンプレートシステム入門 (1) 歴史編 が参考になります。

既にEnliveのナイスなチュートリアル、ドキュメントがあります。

以下はそれぞれの関数の覚え書きになります。

  • html-resource
  • select
  • deftemplate
  • defsnippet


a.html が以下の内容だとする。

<html>
 <head>
   <title>Title here</title>
 </head>
 <body>
   <div class="language">
     <h1 class="name">Clojure</h1>
     <div class="library" id="1">
       <h2 class="name">clojure.contrib.repl-utils</h2>
       <p class="description">
         Provides tools for examining Clojure source code
         and Java classes at the REPL.
       </p>
       <p class="site">
         <a href="http://github.com/richhickey/clojure-contrib/">;
           http://github.com/richhickey/clojure-contrib/
         </a>
       </p>
       <p class="category">meta-clojure</p>
     </div>
   </div>
 </body>
</html>


ここでは enlive の関数を使っているという事が分かるように use ではなく require で en という名前を付けて読み込む。

user> (ns hello
        (:require [net.cgrand.enlive-html :as en]))

出力が見やすいように pprint を use しとく。

hello> (use 'clojure.contrib.pprint)

パース

html-resource は引数で指定された html をClojure で扱い易いようにマップにパースする。

hello> (pprint (en/html-resource "src/html/a.html"))
({:tag :html,
  :attrs nil,
  :content
  ("\n "
   {:tag :head,
    :attrs nil,
    :content
    ("\n   " {:tag :title, :attrs nil, :content ("Title here")} "\n ")}
   "\n "
   {:tag :body,
    :attrs nil,
    :content
    ("\n   "
     {:tag :div,
      :attrs {:class "language"},
      :content
      ("\n     "
       {:tag :h1, :attrs {:class "name"}, :content ("Clojure")}
       "\n     "
       {:tag :div,
        :attrs {:id "1", :class "library"},
        :content
        ("\n       "
         {:tag :h2,
          :attrs {:class "name"},
          :content ("clojure.contrib.repl-utils")}
         "\n       "
         {:tag :p,
          :attrs {:class "description"},
          :content
          ("\n\t Provides tools for examining Clojure source code\n\t
             and Java classes at the REPL.\n  ")}
         "\n       "
         {:tag :p,
          :attrs {:class "site"},
          :content
          ("\n\t "
           {:tag :a,
            :attrs
            {:href "http://github.com/richhickey/clojure-contrib/";},
            :content
            ("\n\t   http://github.com/richhickey/clojure-contrib/\n\t ")}
           "\n       ")}
         "\n       "
         {:tag :p,
          :attrs {:class "category"},
          :content ("meta-clojure")}
         "\n     ")}
       "\n   ")}
     "\n ")}
   "\n\n")})
nil
hello>

最後の nil は pprint の返り値です。
html-resouceの返り値はマップではなくマップが入ったリストであることに注意。
引数が文字列だったらファイルから、URLオブジェクトだったら URL で指定されたところからなど引数で勝手に判断してれるようだ。賢い・・・。

hello> (pprint (en/html-resource (java.io.StringReader.
                                  "<html><body><h1></h1></body></html>")))
({:tag :html,
  :attrs nil,
  :content
  ({:tag :body,
    :attrs nil,
    :content ({:tag :h1, :attrs nil, :content nil})})})
nil
hello>

html-resource部分のソース (swank-clojureだと M-. でjar内のソースを見れるので便利)。なるほど。

パースしたhtmlをノードと呼ぶ事にする。ノードの子要素もノードである。
html全体を包んだノードに名前をつけておく。

hello> (def *mapped-html* (en/html-resource "src/html/a.html"))
#'hello/*mapped-html*

選択

ノードの選択には例えば次のようなノード指定子を使用する。

[:h2.name]

select はノード指定子の合致した部分のノードのリストを返す。

hello> (pprint (en/select *mapped-html* [:h2.name]))
({:tag :h2,
  :attrs {:class "name"},
  :content ("clojure.contrib.repl-utils")})
nil
hello>

ノードの入ったリストが帰ってくることに注意。合致するノードが複数あれば複数帰ってくる。

hello> (pprint (en/select *mapped-html* [:.name]))
({:tag :h1, :attrs {:class "name"}, :content ("Clojure")}
 {:tag :h2,
  :attrs {:class "name"},
  :content ("clojure.contrib.repl-utils")})
nil
hello>

このノードを選択して取り出すというのが Enlive の肝となるところである。選択にはタグやクラス属性、id属性などを目印として使うので、htmlにコードが混ざらない。ノード指定子は CSSでの要素選択と同じ感じで使う。クラスの指定はドットで。idは#で指定する。

hello> (pprint (en/select *mapped-html* [:div#1]))
({:tag :div,
  :attrs {:id "1", :class "library"},
  :content
  ("\n       "
   {:tag :h2,
    :attrs {:class "name"},
    :content ("clojure.contrib.repl-utils")}
   "\n       "
   {:tag :p,
    :attrs {:class "description"},
    :content
    ("\n\t Provides tools for examining Clojure source code\n\t 
             and Java classes at the REPL.\n ")}
   "\n       "
   {:tag :p,
    :attrs {:class "site"},
    :content
    ("\n\t "
     {:tag :a,
      :attrs {:href "http://github.com/richhickey/clojure-contrib/";},
      :content
      ("\n\t   http://github.com/richhickey/clojure-contrib/\n\t ")}
     "\n       ")}
   "\n       "
   {:tag :p, :attrs {:class "category"}, :content ("meta-clojure")}
   "\n     ")})
nil
hello>
  • and 選択
hello> (pprint (en/select *mapped-html* [:div.library :.category]))
({:tag :p, :attrs {:class "category"}, :content ("meta-clojure")})
nil
hello>
  • or 選択
hello> (pprint (en/select *mapped-html* #{[:.description] [:.category]}))
({:tag :p,
  :attrs {:class "description"},
  :content
  ("\n\t Provides tools for examining Clojure source code\n\t and Java classes at the REPL.\n ")}
 {:tag :p, :attrs {:class "category"}, :content ("meta-clojure")})
nil
hello>
  • nth-of-type などは一つ余計に [ ] で囲まないといけないらしい。
hello> (pprint (en/select *mapped-html* [[:p (en/nth-of-type 1)]]))
({:tag :p,
  :attrs {:class "description"},
  :content
  ("\n\t Provides tools for examining Clojure source code\n\t
        and Java classes at the REPL.\n ")})
nil
hello>
hello> (pprint (en/select *mapped-html* [[:p (en/nth-of-type 3)]]))
({:tag :p, :attrs {:class "category"}, :content ("meta-clojure")})
nil
hello>
  • 範囲選択

マップで始点と終点を指定する。(CSSではこれはできないらしい)

hello> (pprint (en/select *mapped-html* {[:h2.name] [:p.site]}))
(({:tag :h2,
   :attrs {:class "name"},
   :content ("clojure.contrib.repl-utils")}
  "\n       "
  {:tag :p,
   :attrs {:class "description"},
   :content
   ("\n\t Provides tools for examining Clojure source code\n\t
             and Java classes at the REPL.\n ")}
  "\n       "
  {:tag :p,
   :attrs {:class "site"},
   :content
   ("\n\t "
    {:tag :a,
     :attrs {:href "http://github.com/richhickey/clojure-contrib/";},
     :content
     ("\n\t   http://github.com/richhickey/clojure-contrib/\n\t ")}
    "\n       ")}))
nil
hello>

加工

いよいよhtmlで用意したテンプレートを元にして動的にhtmlを生成してみる。
deftemplate によってページを加工する関数を作る。

hello> (en/deftemplate libpage "src/html/a.html"
         []
         [:p.description] (en/content "Hello!"))
#'hello/libpage
hello>

deftemplate はマクロで、最初の引数で与えた名前の関数を作成してくれる。
作成された関数を実行するとこんな感じに加工後のhtmlを返してくれる。

hello> (pprint (libpage))
("<html>"
 "\n "
 "<head>\n   <title>Title here</title>\n </head>"
 "\n "
 "<body>"
 "\n   "
 "<div class=\"language\">"
 "\n     "
 "<h1 class=\"name\">Clojure</h1>"
 "\n     "
 "<div id=\"1\" class=\"library\">"
 "\n       "
 "<h2 class=\"name\">clojure.contrib.repl-utils</h2>"
 "\n       "
 "<p class=\"description\">"
 "Hello!"
 "</p>"
 "\n       "
 "<p class=\"site\">\n\t <a href=\"http://github.com/richhickey/clojure-contrib/\">
\n\t   http://github.com/richhickey/clojure-contrib/\n\t </a>\n       </p>"
 "\n       "
 "<p class=\"category\">meta-clojure</p>"
 "\n     "
 "</div>"
 "\n   "
 "</div>"
 "\n "
 "</body>"
 "\n\n"
 "</html>")
nil
hello>

一つの文字列ではなく、細切れの文字列のリストが返ってくるのは、大きな文字列のアロケーションをしたくないからだそうだ。
もう一度、定義を見てみる。

(en/deftemplate libpage "src/html/a.html"
  []
  [:p.description] (en/content "Hello!"))

以下の構成になっている

(en/deftemplate 1.名前 2.htmlの場所
   3.[引数]
   4.セレクタ 5.変換指示)

1.名前 は作成される関数の名前で、その関数への引数を 3.[引数] で指定するが、この例では引数は与えていない。
2.htmlの場所 は内部で前述の html-resource に渡されるので URL オブジェクトとかいろんなのを渡せる。
4.セレクタ 5.変換指示 がページを加工するキーとなるところである。
4.セレクタ で選択されたノード(達)はそれぞれ 5.変換指示 で変換される。左から右にノードが流れるイメージ。
5.変換指示 は "ノードを引数に取り変換後のノードを返す関数" を返す(分かりにくいですが)。
つまりcontentが返すのはノード内の キー :content の値を置換する関数。

hello> (en/content "Hello!")
#<enlive_html$content__2774$fn__2776 net.cgrand.enlive_html$content__2774$fn__2776@67a7d653>
hello> ((en/content "Hello!") {})
{:content ("Hello!")}
hello> ((en/content "Hello!") {:tag :p,
                               :attrs {:class "description"},
                               :content "test"})
{:tag :p, :attrs {:class "description"}, :content ("Hello!")}
hello>

5.変換指示 でよく使いそうなのが予め用意されている。

  • content

引数で受け取ったマップの :content キーを置き換えたマップを返す関数を返す。ノード内の キー:content は html でのタグの内側の要素を表している。

hello> ((en/content "xyz") {})
{:content ("xyz")}
hello> ((en/content "xyz") {:tag :div})
{:content ("xyz"), :tag :div}
hello> ((en/content "xyz" {:tag :p} "abc") {:tag :div})
{:content ("xyz" {:tag :p} "abc"), :tag :div}
hello>
  • html-content

一見、contentと変わらないように見えるが

hello> ((en/html-content "xyz") {})
{:content ("xyz")}

引数の文字列にhtmlが含められているとそれを解釈してくれる。

hello> ((en/html-content "<p>test</p>") {})
{:content ({:tag :p, :attrs nil, :content ("test")})}

ただのcontentではそうはいかない。

hello> ((en/content "<p>test</p>") {})
{:content ("<p>test</p>")}
  • wrap

その名の通り引数のノードをwrapする。

hello> ((en/wrap :div) {})
{:tag :div, :attrs nil, :content [{}]}
hello> ((en/wrap :div) {:tag :p})
{:tag :div, :attrs nil, :content [{:tag :p}]}
  • unwrap

wrapの逆。

hello> (en/unwrap {:tag :div, :attrs nil, :content [{:tag :p}]})
[{:tag :p}]
hello>

unwrap は関数を返すわけではない事に注意する。よく見るとunwrap は :content に名前を付けただけである。

hello> en/unwrap
:content

以下省略

  • set-attr
  • remove-attr
  • add-class
  • remove-class
  • do->
  • clone-for
  • append
  • prepend
  • after
  • before
  • substitute
  • move

5.変換指示 にはノードを引数に取る関数を置けばいいので、自分で定義もできる。

4.セレクタ 5.変換指示 は一組だけでなく何組も列挙できる。
次の例は引数有りで、セレクタと変換指示が複数ある場合。

hello> (en/deftemplate libpage "src/html/a.html"
         [name descri url]
         [:h2.name] (en/content name)
         [:p.description] (en/content descri)
         [:.site :a] (en/do-> (en/set-attr :href url)
                              (en/content url)))
#'hello/libpage

:a の変換指示にある do-> は複数の変換指示をチェインして適用する場合に便利。
実行してみる。

hello> (pprint (libpage "neman.json"
                        "Fast JSON encoder/decoder using Jackson JSON Processor."
                        "http://bitbucket.org/ksojat/neman/";))
("<html>"
 "\n "
 "<head>\n   <title>Title here</title>\n </head>"
 "\n "
 "<body>"
 "\n   "
 "<div class=\"language\">"
 "\n     "
 "<h1 class=\"name\">Clojure</h1>"
 "\n     "
 "<div id=\"1\" class=\"library\">"
 "\n       "
 "<h2 class=\"name\">"
 "neman.json"
 "</h2>"
 "\n       "
 "<p class=\"description\">"
 "Fast JSON encoder/decoder using Jackson JSON Processor."
 "</p>"
 "\n       "
 "<p class=\"site\">"
 "\n\t "
 "<"
 "a"
 " "
 "href"
 "=\""
 "http://bitbucket.org/ksojat/neman/";
 "\""
 ">"
 "http://bitbucket.org/ksojat/neman/";
 "</"
 "a"
 ">"
 "\n       "
 "</p>"
 "\n       "
 "<p class=\"category\">meta-clojure</p>"
 "\n     "
 "</div>"
 "\n   "
 "</div>"
 "\n "
 "</body>"
 "\n\n"
 "</html>")
nil
hello>

部品

defsnippet で部品を構築する。
deftemplate によって作られた関数 libpage は読み込んだ html の部分部分を加工して、構成的には元のテンプレートとほとんど変わらない html を返してくれる。
deftemplate に虎を読み込ませるとライオンが返ってくるようなイメージ(?)。
大きな html を生成したい時は、小さな部品から全体を組み立てるというやり方もある。
defsnippet は虎を読み込んで虎の足を返してくれるような働きをする。部品は何かのリストを生成したい時に便利。

hello> (en/defsnippet lib-model "src/html/a.html" [:.library]
         [{:keys [id name description url category]}]
         [:.library] (en/set-attr :id id)
         [:.name] (en/content name)
         [:.descriptionj] (en/content description)
         [:.site :a] (en/do-> (en/set-attr :href url)
                              (en/content url))
         [:.category] (en/content category))
#'hello/lib-model
hello>

defsnippetによって関数 lib-model が作成されたのでそれを実行してみる。

hello> (pprint
        (lib-model {:id 1, :name "neman.json",
                    :description "Fast JSON encoder/decoder using Jackson JSON Processor.",
                    :url "http://bitbucket.org/ksojat/neman/";
                    :category "JSON"}))
({:tag :div,
  :attrs {:id 1, :class "library"},
  :content
  ("\n       "
   {:tag :h2, :attrs {:class "name"}, :content ("neman.json")}
   "\n       "
   {:tag :p,
    :attrs {:class "description"},
    :content
    ("\n\t Provides tools for examining Clojure source code\n\t 
                 and Java classes at the REPL.\n ")}
   "\n       "
   {:tag :p,
    :attrs {:class "site"},
    :content
    ("\n\t "
     {:tag :a,
      :attrs {:href "http://bitbucket.org/ksojat/neman/";},
      :content ("http://bitbucket.org/ksojat/neman/";)}
     "\n       ")}
   "\n       "
   {:tag :p, :attrs {:class "category"}, :content ("JSON")}
   "\n     ")})
nil
hello>

返される値は文字列ではなくノード(のリスト)であることに注意。まだ内部表現なので他の関数で処理しやすいという利点がある。
もう一度定義を見てみる。

(en/defsnippet lib-model "src/html/a.html" [:.library]
  [{:keys [name description url category]}]
  [:.name] (en/content name)
  [:.descriptionj] (en/content description)
  [:.site :a] (en/do-> (en/set-attr :href url)
                       (en/content url))

以下の構成になっている

(en/defsnippet 1.名前 2.htmlの場所 3.セレクタ
  4.[引数]
  5.セレクタ 6.変換指示)

deftemplate とかなり似ている。違う点は 3.セレクタ によって html 中の何処を対象にするかを選択するところである。つまり、虎から虎の足を選択する箇所である。引数部分の :keys は引数に同名のキーワードの値を結びつけるClojurespecial form
defsnippet によって部品をゲットしたので、部品のリストを生成してみる。

hello> (en/deftemplate libpage "src/html/a.html"
         [libdata]
         [:.library] (en/substitute (map lib-model libdata)))
#'hello/libpage

deftemplate の変換指示の中で先程 defsnippet で作成した lib-model を利用している。リストの生成にはmapを使うという直感的な使い方ができる。
適当なデータを作っておく。

hello> (def *data* [{:id 1, :name "neman.json",
                     :description "Fast JSON encoder/decoder using Jackson JSON Processor.",
                     :url "http://bitbucket.org/ksojat/neman/";,
                     :category "JSON"}
                    {:id 2, :name "sdb",
                     :description "A Clojure library for working with Amazon SimpleDB",
                     :url "http://github.com/richhickey/sdb/tree/master";,
                     :category "database"}])
#'hello/*data*

deftemplate によって作成された関数は細切れの文字列を返してきて見にくいので、ノードに戻しておいた。

hello> (pprint (en/html-resource (java.io.StringReader.
                                  (apply str (libpage *data*)))))
({:tag :html,
  :attrs nil,
  :content
  ("\n "
   {:tag :head,
    :attrs nil,
    :content
    ("\n   " {:tag :title, :attrs nil, :content ("Title here")} "\n ")}
   "\n "
   {:tag :body,
    :attrs nil,
    :content
    ("\n   "
     {:tag :div,
      :attrs {:class "language"},
      :content
      ("\n     "
       {:tag :h1, :attrs {:class "name"}, :content ("Clojure")}
       "\n     "
       {:tag :div,
        :attrs {:class "library", :id "1"},
        :content
        ("\n       "
         {:tag :h2, :attrs {:class "name"}, :content ("neman.json")}
         "\n       "
         {:tag :p,
          :attrs {:class "description"},
          :content
          ("\n\t Provides tools for examining Clojure source code\n\t
                  and Java classes at the REPL.\n ")}
         "\n       "
         {:tag :p,
          :attrs {:class "site"},
          :content
          ("\n\t "
           {:tag :a,
            :attrs {:href "http://bitbucket.org/ksojat/neman/"},
            :content ("http://bitbucket.org/ksojat/neman/")}
           "\n       ")}
         "\n       "
         {:tag :p, :attrs {:class "category"}, :content ("JSON")}
         "\n     ")}
       {:tag :div,
        :attrs {:class "library", :id "2"},
        :content
        ("\n       "
         {:tag :h2, :attrs {:class "name"}, :content ("sdb")}
         "\n       "
         {:tag :p,
          :attrs {:class "description"},
          :content
          ("\n\t Provides tools for examining Clojure source code\n\t
                 and Java classes at the REPL.\n")}
         "\n       "
         {:tag :p,
          :attrs {:class "site"},
          :content
          ("\n\t "
           {:tag :a,
            :attrs
            {:href "http://github.com/richhickey/sdb/tree/master"},
            :content ("http://github.com/richhickey/sdb/tree/master")}
           "\n       ")}
         "\n       "
         {:tag :p, :attrs {:class "category"}, :content ("database")}
         "\n     ")}
       "\n   ")}
     "\n ")}
   "\n\n")})
nil
hello>

以上。
CSSとかあんまり詳しくないので間違ってるかもしれません。first-childの動作とかよく分からなかったです。変換指示のclone-forもよく分かりませんでした。
作者のChristophe Grandさんに教えてもらいました。上の defsnippet は clone-for で代替えできる!