FreeMarker はJavaプログラムがHTMLやXMLなどのテキストを生成する処理を補助するテンプレートエンジンです。
解決したい問題
わたしはJavaプログラムを作りFreeMarkerでHTMLを生成しようとしました。次のようなテンプレートを作りました。
<#macro sampleMarkup>
<#assign clazz="nochange">
<#list segments>
<span class="code">
<#items as segment>
<span class="${clazz}">${segment}</span>
</#items>
</span>
</#list>
</#macro>
プログラムを実行すると下記のようなStringが出力されました。
<span class="code">
<span class="nochange"> {"cat": "Nikolai, Marcus and Ume",
</span>
<span class="nochange"> "greeting": "Hello, world!"}
</span>
</span>
わたしはこの結果に不満だった。わたしはこういう出力が欲しい。
<span class="code"><span class="nochange"> {"cat": "Nikolai, Marcus and Ume",</span><span class="nochange"> "greeting": "Hello, world!"}</span></span>
不満な箇所を列挙しよう。下記のとおり。
- 出力の各行の
<span
の前にインデントとしての空白がある。この空白を除去してゼロにしたい。 -
<span>
と</span>
に挟まれたテキストの中にある空白は意味ある空白だ。意味ある空白には手をつけずに保持したい。元に空白が4個あったら4個をそのまま出力したい。 - テンプレートでFreeMarkerの
<#list>
ディレクティブを使った。そのため<span>
タグが複数行に分かれてしまった。わたしはこの出力全体を1行にしたい。
なぜ1行にしたいのか?
-
テンプレートから出力されたHTMLをブラウザで表示してみると
<span>
タグの周りに改行文字と空白文字があるかないかによって微妙に異なる表示がされた。複数行の場合の表示が受け入れ難かった。だから1行にしたいと思った。 -
<span>
タグはインライン要素だから改行無しに1行に並んでいる方がHTMLコードから意味を読み取る上で好ましい。<span>
が複数行に分かれて書かれていると<div>
などのブロック要素とパッと見に区別がつかなくて読みにくい。
解決方法
FreeMarkerには組み込みのディレクティブ <#compress>
があって、空白を除去してくれる。ところが<#compress>
はわたしの望むところとは少し違う動きをする。
- 行頭のインデントは除去してくれる。
- しかし
<span>
と</span>
に挟まれた有意味な空白もcompressしてしまう。4個の空白が1個にされてしまう。 -
<#compress>
は改行文字を残す。
ちなみに <@compress singline_line=true>
という組み込みディレクティブもあったので試した。これは改行文字を除去してくれる。しかし<span>
と</span>
に挟まれた有意味な空白4つまでもcompressして1個にしてしまった。だからこれもわたしにとって使用不可だった。
しようがない、わたしは自分のためにカスタムなcompressディレクティブを作ることにした。
説明
成果物のありか
GitHubにプロジェクトがあリます。
タグ 0.2.0 で<@compressToSingleLine>
の実装を追加しました。このプロジェクトの成果物を格納したjarをMavenCentralレポジトリで公開しました。
カスタム・ディレクティブの実装
テンプレートの書き方
<@compressToSingleLine>
<#-- 任意のコード -->
</@compressToSingleLine>
compressToSingleLine
ディレクティブはパラメータを取りません。
ユーザが自作すべきプログラム
FreeMakerを駆動してテキストを生成するJavaコードの例を示します。
package com.kazurayam.freemarker;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class CompressToSingleLineDirectiveTest extends TestBase {
public CompressToSingleLineDirectiveTest() throws IOException {
super();
}
@Test
public void test_execute() throws IOException, TemplateException {
/* set data into the model */
List<String> segments = Arrays.asList(
" {\"cat\": \"Nikolai, Marcus and Ume\",\n",
" \"greeting\": \"Hello, world!\"} \n");
model.put("segments", segments);
/* Get the template (uses cache internally) */
Template temp = cfg.getTemplate("compressToSingleLineDemo.ftlh");
/* Merge data-model with template */
Writer out = new StringWriter();
temp.process(model, out);
String output = out.toString();
assertNotNull(output);
System.out.println("---------------------");
System.out.println(output);
System.out.println("---------------------");
BufferedReader br = new BufferedReader(new StringReader(output));
List<String> lines = new ArrayList<>();
String line;
while ((line = br.readLine()) != null) {
lines.add(line);
}
assertEquals(1, lines.size(), "should be single line");
assertTrue(lines.get(0).startsWith("<span"), "^\\s+ should be trimmed");
assertTrue(output.contains("<span class=\"nochange\"> {"cat"),
"indent of text inside <span> tags should be preserved");
}
}
このclassは TestBase
クラスをextendしています。TestBase
クラスがFreeMakerの稼働環境を初期化しています。
package com.kazurayam.freemarker;
import freemarker.template.Configuration;
import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateModelException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
public class TestBase {
protected Configuration cfg;
protected Map<String, Object> model;
public TestBase() throws IOException {
Path projectDir = Paths.get(System.getProperty("user.dir"));
/* ---------------------------------------------------------- */
/* You should do this ONLY ONCE in the whole application lifecycle */
/* Create and adjust the configuration singleton */
cfg = new Configuration(Configuration.VERSION_2_3_31);
Path templatesDir = projectDir.resolve("src/test/resources/freemarker_templates");
cfg.setDirectoryForTemplateLoading(templatesDir.toFile());
// Recommended settings for new projects:
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
cfg.setFallbackOnNullLoopVariable(false);
cfg.setBooleanFormat("c");
cfg.setOutputEncoding("UTF-8");
// add custom directives
try {
cfg.setSharedVariable("readAllLines", new com.kazurayam.freemarker.ReadAllLinesDirective());
Path store = projectDir.resolve("src/test/fixture").resolve("store");
cfg.setSharedVariable("baseDir", store.normalize().toAbsolutePath().toString());
//
cfg.setSharedVariable("uppercase", new UpperCaseDirective());
//
cfg.setSharedVariable("compressToSingleLine", new CompressToSingleLineDirective());
} catch (TemplateModelException e) {
throw new RuntimeException(e);
}
/* ---------------------------------------------------------- */
/* You usually do these for MULTIPLE TIMES in the application life-cycle: */
/* Create a data-model */
model = new HashMap<>();
}
}
compressToSingleLine
という名前のshared variableを作りCompressToSingleLineDirective
クラスのインスタンスを設定していることに注目してください。これによってカスタムなディレクティブ <@compressToSingleLine>
をテンプレートの中で使うことが可能になります。
テンプレートの記述例
<#-- compressToSingleLineDemo.ftlh -->
<#-- sample markup text will be printed straight -->
<@sampleMarkup />
<#-- custom directive name "compressToSingleLine" is defined as a shared variable. See TestVase.java. -->
<@compressToSingleLine>
<@sampleMarkup/>
</@compressToSingleLine>
<#macro sampleMarkup>
<#assign clazz="nochange">
<#list segments>
<span class="code">
<#items as segment>
<span class="${clazz}">${segment}</span>
</#items>
</span>
</#list>
</#macro>
出力結果
---------------------
<span class="code">
<span class="nochange"> {"cat": "Nikolai, Marcus and Ume",
</span>
<span class="nochange"> "greeting": "Hello, world!"}
</span>
</span>
<span class="code"><span class="nochange"> {"cat": "Nikolai, Marcus and Ume",</span><span class="nochange"> "greeting": "Hello, world!"}</span></span>
---------------------
既知の不具合
<span>
と</span>
の間にあるテキストが改行を持っていたとしよう。こんなふうに。
<span>Hello, Alice!
Hello, Bob!</span>
<@compressToSingleLine>
を適用するとこうなります。
<span>Hello, Alice!Hello, Bob!</span>
有意味な改行を除去してしまっている。これはあまりよろしくないですね。
でも、わたしがいま取り組んでいるアプリケーションでは<span>
と</span>
の間に改行が含まれることが絶対に無いとわかっている。だからわたしは問題視しません。
結論
FreeMarkerテンプレートの出力から改行とインデントを削って1行にするディレクティブ <@compressToSingleLine>
を開発することができました。