ツリー構造に対する簡単なテンプレート処理系を考えます。前半ではXMLベースで考えてみます。後半では、そのアイディアを通常のHTMLにマップして、ブラウザ上で動くようにJavaScriptで実装してみます。プルーフ・オブ・コンセプトの目的なので、プログラムは書いただけでろくに動かしてないです。ソースコードはたいした量ではないので最後に貼り付けておきます(修正したら*1、それはどっかに置きます)。
[追記]ソースは https://bitbucket.org/m_hiyama/yet-uncertain に置きました。[/追記]
5年前(2007年)の記事「圏論やモナドが、どうして文書処理やXMLと関係するのですか?」の続きと言えなくもないです。表立ってモナドは出てきませんけど。
目次:
まずはXMLで考える
XML文書ツリーがあったとき、いくつかの末端(リーフ)ノードがプレースホルダとして指定されていれば、それはテンプレートとなります。どうやってプレースホルダを指定するか? 名前空間を使えば簡単です。
<sxt:placeholder name="foo" />
接頭辞 sxt: はテンプレート記述専用に予約された名前空間URIに束縛されているとします。仮に、名前空間URIを http://xmlns.chimaira.org/Sixteen としておきます*2。
次はテンプレートの例です。
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sxt="http://xmlns.chimaira.org/Sixteen" > <head> <title><sxt:placeholder name="title" /></title> </head> <body> <h1><sxt:placeholder name="title" /></h1> <sxt:placeholder name="content" /> </body> </html>
このXML文書はXHTML文書として妥当(valid)ではありませんが、これはテンプレートなので問題にはなりません。展開結果が不正だと問題ですが。
テンプレートのプレースホルダ(テンプレート変数)を埋めるためのコンテキストは、例えば次のように定義します。
<sxt:context xmlns="http://www.w3.org/1999/xhtml" xmlns:sxt="http://xmlns.chimaira.org/Sixteen" > <sxt:replacement name="title">Simple XML Template Engine (Sixteen)</sxt:replacement> <sxt:replacement name="content"> <p>Simple XML Template Engine (Sixteen) は、ツリー構造に対する簡単なテンプレート処理系です。</p> <p>プレースホルダは、sxt:placeholder 要素で指定します。</p> </sxt:replacement> </sxt:context>
sxt:replacement要素の内容が、プレースホルダ要素とそっくり入れ替わります。上記のテンプレートをこのコンテキストで展開すると次のようになります。(xmlns:sxt 名前空間宣言は削り落としてもかまいません。)
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sxt="http://xmlns.chimaira.org/Sixteen" > <head> <title>Simple XML Template Engine (Sixteen)</title> </head> <body> <h1>Simple XML Template Engine (Sixteen)</h1> <p>Simple XML Template Engine (Sixteen) は、ツリー構造に対する簡単なテンプレート処理系です。</p> <p>プレースホルダは、sxt:placeholder 要素で指定します。</p> </body> </html>
次にHTMLで考える
XMLに関して考えた今の方法を、HTMLとJavaScriptの世界に移してみましょう。
名前空間やカスタム要素はHTMLとあまり相性が良くないので、既存要素に対してコンベンション(お約束)ベースのマークアップを使うことにします。例えば、プレースホルダは次のようにマークアップします。
<span class="sxt-placeholder" name="foo" ></span>
class属性(の値トークンの1つ)とname属性だけが目印となるので、タグ名(要素名)は何でもかまいません。
テンプレートコンテキストのほうはマークアップを使わずに、ブラウザ内にオブジェクト構造を直接作ってしまうことにします。
var context = { title: document.createTextNode("Simple XML Template Engine (Sixteen)"), content: (function (){ var f = document.createDocumentFragment(); var p1 = document.createElement('p'); var t1 = document.createTextNode( "Simple XML Template Engine (Sixteen) は、ツリー構造に対する簡単なテンプレート処理系です。" ); p1.appendChild(t1); f.appendChild(p1); var p2 = document.createElement('p'); var t2 = document.createTextNode( "プレースホルダは、sxt:placeholder 要素で指定します。" ); p2.appendChild(t2); f.appendChild(p2); return f; })() };
context.title と context.content は、DOMツリーに直接挿入可能なノードです。
ここまで来れば、テンプレート(プレースホルダ付きのツリー)とコンテキストから展開結果を出力する方法は分かるでしょう。
少しだけ実用性も
原理的な話はこれで終りですが、少しだけ実用的な機能を追加しておきます。
プレースホルダ(テンプレート変数)を単純に置き換えるモノと配列で置き換えるモノの二種類を準備します。それぞれを、単純プレースホルダと配列項目プレースホルダと呼び(マンマや)、次のようにマークアップします。
<!-- 単純プレースホルダ --> <span class="sxt-simple" name="foo" ></span> <!-- 配列項目プレースホルダ --> <li class="sxt-array-item" name="bar" ></li>
配列項目プレースホルダは、ツリー(DOMノード)の並びに展開されます。配列項目プレースホルダに対応するコンテキストデータはDOMノードの配列となります。
さらに、コンテキスは入れ子にしていいとしましょう。すると、コンテキストはJavaScriptのオブジェクトであり、プロパティ値は次のどれかになります。
- DOMノード
- DOMノードの配列
- コンテキスト・オブジェクト(オブジェクトが入れ子になる)
こうすると、コンテキストデータの指定には、単なる名前ではなくてパスが必要になります。foo.bar のようなドット区切りパスを使うことにして、プレースホルダのname属性にドット区切りパスを書いてもいいことにします。(X)HTMLのname属性の値には、名前だけでなくドットを含めることもできるので大丈夫です。
コンテキストから適切な置換値(replacement)が見つからないと、プレースホルダは削除されます。ただし、sxt-fallback がclass属性に含まれるときは、プレースホルダをそのままフォールバックに使うことにします。配列項目プレースホルダの場合にはさらに次の処理をすることにしましょう。
- 配列項目プレースホルダが削除されたときは、親要素を見る。親要素が“空”になっており、親要素のclass属性に sxt-remove-if-empty が含まれるなら、親要素も削除する。
内容が空白だけのテキストノードだけなら、ここでは“空”な要素とみなします。
このような機能をどう使うかは次の例で見当が付くでしょう。
<ol class="sxt-remove-if-empty"> <li class="sxt-array-item" name="products" >製品情報 … …</li> </ol>
その他細かいこと
コンテキストは入れ子を許すオブジェクトですが、配列でもかまいません。コンテキストの成分にアクセスするには、a.2.b.3 のような、名前と番号が混じったパスを使うことができます。ソースコードのsxt.get関数を見てもらえればわかるでしょう。名前・番号混じりのパスはCatyでずっと使っているもので、なかなか便利です。
プレースホルダがフォールバックとして使われると、class属性トークン sxt-fallback が sxt-simple-fallback または sxt-arry-item-fallback に変わります。これを利用して、フォールバックした部分にスタイルを付けることができます。
ソースコードは RequirJS を利用して書いています。RequireJS、初めて使いました。ソースコードの最初の3行と最後の2行を削れば、通常のJavaScriptライブラリとして使えます。RequireJS を使う場合は、次がメインファイル(data-main属性に指定するモジュール)です。define._namespace = 'util'; とかは、RequireJS の代替(一時しのぎ)で使う dummy-require の設定です。無視してけっこうです。ここらのことは、いずれ書くかもしれません。
/* sixteen-main.js -*- coding: utf-8 -*- * Sixteen main for RequireJS */ require(['sixteen'], function(sxt) { var global = (function() {return this;}).apply(null, []); global.sxt = sxt; });
ソースコード
util.js
/* util.js -*- coding: utf-8 */ if (this && !this.define) throw new Error("use RequireJS or dummy-define"); define._namespace = 'util'; define(function() { var util = {}; util.contains = function(arr, value) { for (var i in arr) { if (arr.hasOwnProperty(i) && arr[i] === value) { return true; } } return false; }; return util; });
sixteen.js
/* sixteen.js -*- coding: utf-8 */ /* * Sixteen -- Simple XML/HTML Template Engine (a.k.a. SXT) * * You're Sixteen ( http://en.wikipedia.org/wiki/You're_Sixteen ) * */ if (this && !this.define) throw new Error("use RequireJS or dummy-define"); define._namespace = 'sxt'; define(['util'], function(util) { var sxt = {}; var global = (function() {return this;}).apply(null, []); sxt._NumberRegExp = /\d+/; sxt.get = function(ctx, path) { var a = []; var segments = path.split('.'); for (var i = 0; i < segments.length; i++) { var seg = segments[i]; if (seg.match(sxt._NumberRegExp)) { seg = parseInt(seg); } a.push(seg); } var curr = ctx; for (i = 0; i < a.length && curr; i++) { var seg = a[i]; if (!curr[seg]) { return undefined; } curr = curr[seg]; } return curr; }; sxt.expand = function (tpl, ctx) { var simples = tpl.getElementsByClassName('sxt-simple'); for (var i = 0; i < simples.length; i++) { var node = simples[i]; var path = node.getAttribute('name'); sxt.replaceByTree(node, ctx, path); } var arrayItems = tpl.getElementsByClassName('sxt-array-item'); for (i = 0; i < arrayItems.length; i++) { var node = arrayItems[i]; var path = node.getAttribute('name'); sxt.replaceByHedge(node, ctx, path); } }; sxt.replaceByTree = function(node, ctx, path) { if (!path) { return; } var repl = sxt.get(ctx, path); if (!repl || !(repl instanceof Node)) { sxt.disposeSimple(node); } else { node.parentNode.replaceChild(repl, node); } }; sxt.replaceByHedge = function(node, ctx, path) { if (!path) { return; } var repl = sxt.get(ctx, path); if (!repl || !(repl instanceof Array) || repl.length === 0) { sxt.disposeArrayItem(node); } else { sxt.insertNodesBefore(repl, node); node.parentNode.removeChild(node); } }; sxt.disposeSimple = function(node) { sxt._dispose(node, 'simple'); }; sxt.disposeArrayItem = function(node) { var parent = node.parentNode; sxt._dispose(node, 'array-item'); sxt._disposeParent(parent); }; sxt.insertNodesBefore = function(nodes, ref) { var frag = document.createDocumentFragment(); for (var i = 0; i < nodes.length; i++) { frag.appendChild(nodes[i]); } var parent = ref.parentNode; parent.insertBefore(frag, ref); }; sxt._SpacesRegExp = / +/; sxt._dispose = function(node, which) { var className = node.getAttribute('class'); var classNames; if (className) { classNames = className.split(sxt._SpacesRegExp); } else { classNames = []; } if (util.contains(classNames, 'sxt-fallback')) { var newClassNames = ''; for (var i = 0; i < classNames.length; i++) { var name = classNames[i]; if (name === 'sxt-simple' || name === 'sxt-array-item' || name === 'sxt-fallback' ) { continue; } newClassNames += (' ' + name); } if (which === 'simple') { newClassNames += ' sxt-simple-fallback'; } else { newClassNames += ' sxt-array-item-fallback'; } node.setAttribute('class', newClassNames); } else { node.parentNode.removeChild(node); } }; sxt._disposeParent = function (node) { var className = node.getAttribute('class'); var classNames; if (className) { classNames = className.split(sxt._SpacesRegExp); } else { classNames = []; } if (util.contains(classNames, 'sxt-remove-if-empty')) { if (sxt._isEmpty(node)) { node.parentNode.removeChild(node); } } }; sxt._WhitespaceRegExp = /[ \t\n\r]+/; sxt._isEmpty = function(node) { var children = node.childNodes; for (var i = 0; i < children.length; i++) { var child = children[i]; if (child.nodeType === Node.TEXT_NODE && child.nodeValue.match(sxt._WhitespaceRegExp)) { continue; } return false; } return true; }; return sxt; });