Freemarker best practices

Background

This article outlines best practices to follow when implementing Funnelback templates.

The usage of simple.ftl

The simple.ftl (default) template is a demonstration template and should not be used for any production implementation. Do not use this as a basis to apply a client’s look and feel, as the template contains a lot of redundant code you will not require.

Once a project cutup has been designed the recommended course of action is to move the simple.ftl to debug.ftl and to use this for debugging only. The debugging template should be removed when the project is completed.

Templating from a cut-up

For any project a HTML mockup of the search results page (SERP) should be put together. Once completed, the recommended course of action is as follows:

Ensure all URLs in the cut-up are absolute.

  1. Copy the cutup as-is into the simple.ftl, including the standard ftl header and Funnelback macro library imports.

  2. Confirm the static cut-up renders as expected when viewed via the search.html endpoint. This validates the CSS and JS includes.

    This is a milestone point to begin template implementation.

  3. Implement one feature at a time, replacing the static placeholder in the cut-up with functional Freemarker code. It is recommended to begin with simpler functionality E.g. begin with the search summary, then pagination. Use the template code from your renamed debug.ftl as a guide for how the Freemarker template is constructed, adjusting the styling to suit your cutup.

  4. Once a specific feature has been completed in the Freemarker template, save and publish (unless the search is live).

  5. Confirm the functionality works on the search endpoint.

  6. Repeat from step 4 until all functionality on the template has been implemented.

The main reasoning to loop through a single feature at a time is to ensure that any issues or errors in the templating are easily identifiable and correctable. If you implement a large section of the template without checking intermittently it will be extremely difficult to resolve and could lose significant time debugging.

Consider splitting the template into sub-components.

This approach is particularly useful when dealing with multiple result types in a meta collection. However, a balance needs to be found between creating these sub-components which are individually easier to edit and understand, and the number of files that make up a template. As more files are added it becomes difficult to find where a change needs to be made as the template may nest components at multiple levels.

You can split templates into multiple chunks and use the include directive of Freemarker to pull them in. Valid reasons for this are:

  • The simple.ftl is growing beyond 1k lines of code.

  • A piece of the template has to be reused in multiple locations.

  • A particular component (e.g. facets, results loop) will become unwieldy to manage.

E.g. a switch statement on the results loop with very complex markup per result would benefit from moving the result layout itself to new, smaller, templates.

...
<@s.Results>
...
<#switch s.result.collection>
  <#case "youtube">
    <#include "result_youtube.ftl">
  <#break>
  <#case "web">
    <#include "result_web.ftl">
  <#break>
  <#default>
    <#include "result_default.ftl">
  <#break>
</#switch>
...
</@s.Results>
...
The escape directive in Freemarker applies per template and so must be added to every template file you create at top and bottom.

Template comments

As templates are essentially HTML embedded with Freemarker code, it is important that comments are placed to allow a new developer to quickly locate components.

You should use Freemarker comments:

<#-- This is a freemarker comment, I will not show on the frontend -->

Always place comments at:

  • Specific component start (and end if the block is big) for facets, results loop, contextual navigation, start of body (helps for partial), footer, SERP-specific code blocks.

  • Any complex output such as date parsing and formatting

  • Any custom macro being used

Defensive coding

Whilst Freemarker is quite verbose when triggering errors, from a front-end user perspective this can visually break the SERP or even render a blank (if the error is unrecoverable). For that reason, it is extremely important to code defensively.

  • When printing a data model variable, use the ! Freemarker modifier to indicate what should be shown if it does not exist.

  • If there is surrounding boilerplate HTML that is conditional on the variable'’'s existence, use an if-statement instead.

  • When parsing dates always use the <#attempt><#recover></#attempt> directives. This is the try-catch of Freemarker. Date parsing errors are unrecoverable.

  • The same applies for the ?eval modifier, always use the attempt-recover method.

Some more specific examples are below:

Check for missing and null values

Special care must be taken when displaying values in a template that the value isn’t missing or evaluates to null as it will result in a FreeMarker error.

An approach to prevent this is to check that a value exists before displaying it using the ?? construct:

<#-- INCORRECT -->
<p class="description">${s.result.metaData["c"]}</p>

<#-- CORRECT -->
<#if s.result.metaData["c"]??><p class="description">${s.result.metaData["c"]}</p></#if>

Alternatively the ! construct can be used to fall back to a default value. For example ${author!"N/A"} will display the author, or fall back to "N/A" if the author doesn’t exist.

<#-- INCORRECT -->
<p class="description">${s.result.metaData["c"]}</p>

<#-- CORRECT usage -->
<#-- with explicit default value -->
<p class="description">${s.result.metaData["c"]!"My default"}</p></#if>

<#-- with implicit default value (empty string) -->
<p class="description">${s.result.metaData["c"]!}</p></#if>

<#-- When using modifiers, use brackets to explicitly define scope around the base value and the '!' value -->
<p class="description">${(s.result.metaData["c"]!"My default")?lower_case?replace("donotwant", "")}</p></#if>

Escape output wherever possible

Variables printed out in Freemarker templates should always be appropriately escaped for the output format that is being produced. This is important as it prevents injection attacks from occurring.

Since 15.8 the recommended way of escaping templates is to use the ftl output_format property. If you are creating a template from scratch then this is the recommended way of escaping.

The advice below covers the former way of escaping as this is still what is implemented within the default Funnelback template.

Prior to this the recommended way of escaping was to use the Freemarker escape directive, which has been used in the default template since v14 (and is still used in the current 15.20 template). The default template wraps all output in a <#escape> tag to prevent XSS issues. Regions within the form can choose to ignore this directive using the <#noescape> tag. However use this with caution.

The escape macro should be configured to use the appropriate escape for what is being returned.

For templates that return HTML use:

<#escape x as x?html>

For template that return JSON use:

<#escape x as x?json_string>

The code below shows how the <#escape> tag is generally used in a template:

<#ftl encoding="utf-8" />
<#import "/web/templates/modernui/funnelback_classic.ftl" as s/>
<#import "/web/templates/modernui/funnelback.ftl" as fb/>
<#escape x as x?html>
  ...
  <#-- Disable escaping on this region -->
  <#noescape>...</#noescape>
  ...
</#escape>

Note that the escape here applies only to the file it appears in - If you use macros imported from another file, that imported file must handle escaping itself. See details and examples at http://freemarker.org/docs/ref_directive_escape.html

When to use <#attempt>: ?eval and other modifiers

The ?eval modifier allows you to parse a JSON string into a set of Freemarker objects, this can be very powerful but comes with risks: if the eval fails for any reason, it will count as a template error. For that reason you must use the <#attempt> macro:

<#-- INCORRECT -->
<#assign myList = s.result.metaData["L"]?eval />
<#list myList as listItem>
    ...
</#list>

<#-- CORRECT -->
<#attempt>
    <#assign myList = s.result.metaData["L"]?eval />
    <#list myList as listItem>
        ...
    </#list>
<#recover>
    <#-- here you can print alternative output when eval fails -->
</#attempt>

This mechanism is similar to a Java try-catch in terms of functionality.

Avoid data cleansing in the template

Data cleansing within the template is not recommended as it only affects the output of the html endpoint (search.html), and only when the specific template is applied.

Consider whether post-process hook scripts or data cleansing of content in a filter or at the sources is more appropriate than inside a template.

Use temporary variables for data cleansing

If data cleansing within the template is unavoidable then you should use temporary variables to hold the cleaned values. This will help keep the Freemarker code cleaner and also enable reuse of the cleaned variable:

<#-- NON-PREFERRED -->
<span class="personName">Name: ${s.result.liveUrl?replace('http:..www.+\/person/','','r')?replace('\/.*$','','r')?replace('-', ' ')?capitalize?html}</span>

<#-- PREFERRED -->
<#assign personName = s.result.liveUrl?replace('http:..www.+\/person/','','r')?replace('\/.*$','','r')?replace('-', ' ')?capitalize?html />
<span class="personName">Name: ${personName}</span>

Using a template to return something other than HTML

Returning CSV or JSON

Consider using the all results endpoint if you only require result data (i.e. from response.results).

However, for custom columns and JSON structure you will need to use a normal template and the html endpoint.

Ensure appropriate escaping is applied

Be aware of any special escaping rules that might need to be applied for the format that is being returned.

Make use of the built-in escape functions where possible (e.g. for JSON use an <#ftl output_format="json"> or the <#escape x as x?json_string> tag).

Specify the correct MIME type

Typical non-HTML variants include CSV (query completion files, tabular exports of results), RSS, GeoJSON. See also: ui.modern.form.TEMPLATENAME.contenttype

This should be added to the profile’s profile.cfg.

# Template named 'csv-export' returns CSV
ui.modern.form.csv-export.content_type=text/csv

# Template named 'rss' returns RSS
ui.modern.form.rss.content_type=application/rss+xml

Specify a content-disposition header

When accessing non-html content it is often desirable to force the browser to open a download dialog. This can be done by setting the content-disposition HTTP header for the form that specifes a filename. This should be done in addition to setting the mime type as described above.

This should be added to the profile’s profile.cfg.

# Template named 'csv-export' returns CSV
ui.modern.form.csv.content_type=text/csv
# Template named 'csv' should be downloaded as 'export.csv'
ui.modern.form.csv.headers.1=Content-Disposition: attachment; filename=export.csv

Remote includes

The <@s.IncludeUrl> macro can be used to remotely include content within a template (similar to a server side include)

IncludeUrl against a Funnelback server

  • When including content from the same server always use the localhost address and public http or https port. This will avoid creating a dependency on a specific server (for example the admin server), and also avoid other network infrastructure such as load balancers that may slow down the request.

  • Don’t use <@s.IncludeUrl> to request additional search results - use extra searches instead.

IncludeUrl caching and timeout

The configuration of caching and request timeouts will greatly impact the performance of the search results:

  • If the request timeout is high and the remote site is slow to respond, the search result page will become slow as well as it waits for the remote server to return. Usually, it’s best to not change the default timeout unless there’s a good reason.

  • Similarly, if the cache expiry is set to a low value, the remote server might be requested for each search request. If the form has multiple IncludeUrl cals the impacts are even worse.

Cache expiry is usually set to a low value during development (e.g. 1) to force the content to be re-fetched for each query. Once in production, set it to a sensible value. It will typically depend on how you expect the remote content to change:

  • For header / footers and things that rarely change, set the expiry to 1 day (3600 seconds * 24 hours = 86400) or more

  • For more dynamic content that changes more often, adjust accordingly. e.g. If you know that the remote content is refreshed every hour, set the expiry to 3600.

  • When setting the timeout value note that you need to set an integer value (and not a string) in the IncludeUrl macro. e.g. <@s.IncludeUrl expiry=3600> and not <@s.IncludeUrl expiry="3600">.

  • There should be no reason to have an expiry shorted than 5 min unless the template is still under development. If that’s the case, a different approach might be better (e.g. fetching the content in a hook script and injecting it into the custom data element of the data model or having it in a separate collection).

Custom macro libraries

Grouping custom macros into libraries (separate FTL files) is encouraged for readability and re-usability.

Custom libraries should be stored in the collection configuration folder, not the profile folder. The rationale is that macro rarely changes and maintaining multiple versions adds complexity and confusion.

Defined parameter variables

Instead of using a string parameter to map to reference variables, use the defined variable names as these will always exist regardless of if they appear within the query string:

  • To access the collection ID use question.collection.id instead of question.inputParameterMap["collection"] or other variants.

  • To access the profile ID use question.profile instead of question.inputParameterMap["profile"] or other variants.

  • To access the template ID use question.form instead of question.inputParameterMap["form"] or other variants.

  • To access the query terms use question.query instead of question.inputParameterMap["query"] or other variants.

Use relative URLs

Always prefer using relative URLs when linking separate resources (where appropriate), by order of preference:

  • Completely relative URLs under /s/ (e.g. href="search.html", src="resources/images/image.png")

  • Absolute URLs without a domain name (e.g. href="/s/search.html", src="/s/resources/images/images.png")

  • URLs with a domain name, but without a protocol, for HTTP/HTTPS compatibility (e.g. href="//example.com/s/search.html")

  • collection.cfg options such as ui.modern.search_link.

This applies to:

  • resources linked from search templates

  • resources linked from CSS files (e.g. background: url('/s/resources/…​'); )

This makes the collection more portable as there will be nothing to change when it moves across servers and also makes testing easier by avoiding various problems with cross-domain requests and/or HTTP vs HTTPS requests.