FreeMarker

spider-silk-freemarker renders with FreeMarker instead of the jte bundled with core. It is a separate module for the same reason Tomcat is. A tie to one engine belongs in the artifact’s name, and core stays a web tier with one template dependency rather than four.

Installation

dependencies {
    implementation('net.benelog.spidersilk:spider-silk-freemarker:1.1.0') {
        exclude group: 'gg.jte' // core's jte, unused here
    }
}

Excluding jte is optional. It only keeps an unused engine off the classpath.

Rendering

new App()
        .templates(new FreeMarkerTemplates("freemarker"))
        .get("/decks/{deckId}", req ->
                WebResponse.template("deck", Model.of("deck", service.deck(req.pathParamLong("deckId")))));

That renders classpath:/freemarker/deck.ftlh, read as UTF-8 with the parsed template cached. model.asMap() is the data model, so ${deck.title} reaches the deck entry of Model.of("deck", …​).

Escaping is a decision here

new FreeMarkerTemplates(root) sets HTMLOutputFormat, so ${} is HTML-escaped and ${x?no_esc} writes one value unescaped. FreeMarker itself escapes nothing until an output format says to. That default is the one place it differs from the other three engines, and the main reason this module exists rather than a one-liner.

The default suffix is .ftlh, the extension FreeMarker reads as "this one is HTML" on its own. The escaping is therefore stated twice over: once in the configuration, and once in every template’s name. A suffix of your own drops the statement in the name, but the configuration still escapes:

app.templates(new FreeMarkerTemplates("freemarker") // classpath:/freemarker
        .suffix(".ftl")); // .../deck.ftl, still escaped

The suffix is appended, never checked for, so a name that still carries its extension is looked up with the suffix twice over. The classpath loader appends no extension of its own, so suffix is the only one in play.

A configuration of your own

The other constructor takes a Configuration, which is where an object wrapper, shared variables, or a file-system loader go. It is handed over whole, so the escaping, the exception handling, and the encoding are yours to set again.

Configuration configuration = new Configuration(Configuration.VERSION_2_3_34);
configuration.setTemplateLoader(new ClassTemplateLoader(loader, "freemarker"));
configuration.setDefaultEncoding("UTF-8");
configuration.setOutputFormat(HTMLOutputFormat.INSTANCE); // or nothing is escaped
configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
configuration.setSharedVariable("siteName", "Spider Silk");

app.templates(new FreeMarkerTemplates(configuration));

RETHROW_HANDLER is what the built-in configuration uses, and what a server wants. A template that fails then reaches app.exception(…​) instead of writing a stack trace into a half-rendered page. FreeMarker’s own default, DEBUG_HANDLER, writes the stack trace into the page.

Reflection

FreeMarker reads ${deck.title} through its object wrapper, which is reflective for a record or a bean. That is the module’s reflection, not core’s. See The Scope of "No Reflection" for where the rule applies. Model values that are maps rather than records or beans avoid even that reflection.