tessl install tessl/maven-io-quarkus--quarkus-qute@3.30.0Offer templating support for web, email, etc in a build time, type-safe way
Template extensions provide a powerful way to add custom methods and properties to objects used in Qute templates. This document covers the @TemplateExtension annotation for creating custom extensions, as well as all built-in extension methods provided by Quarkus.
Template extensions allow you to:
str:, config:, time:)Extensions are implemented as static methods annotated with @TemplateExtension and automatically generate ValueResolver instances at build time.
The @TemplateExtension annotation generates a ValueResolver for annotated methods or all eligible methods in an annotated class.
@Retention(RUNTIME)
@Target({ METHOD, TYPE })
public @interface TemplateExtension {
String METHOD_NAME = "<<method name>>";
String ANY = "*";
int DEFAULT_PRIORITY = 5;
String matchName() default METHOD_NAME;
String[] matchNames() default {};
String matchRegex() default "";
int priority() default DEFAULT_PRIORITY;
String namespace() default "";
@Retention(RUNTIME)
@Target(PARAMETER)
@interface TemplateAttribute {
String PARAMETER_NAME = "<<parameter name>>";
String value() default PARAMETER_NAME;
}
}Template extension methods must:
The simplest extension method uses the method name to match template property access:
@TemplateExtension
public class ItemExtensions {
static BigDecimal discountedPrice(Item item) {
return item.getPrice().multiply(new BigDecimal("0.9"));
}
}Template usage:
{item.discountedPrice}The first parameter (Item item) determines the base object type. This method matches when item resolves to an Item instance.
Use matchName to specify a different property name:
@TemplateExtension(matchName = "discounted")
static BigDecimal discountedPrice(Item item) {
return item.getPrice().multiply(new BigDecimal("0.9"));
}Template usage:
{item.discounted}Use matchNames to match several property names. Requires an additional String parameter for the actual name:
@TemplateExtension(matchNames = {"plus", "+"})
static Integer add(Integer number, String name, Integer other) {
return number + other;
}Template usage:
{count.plus(5)}
{count + 5}Use matchName = ANY (or matchName = "*") to match any property name. Requires an additional String parameter:
@TemplateExtension(matchName = ANY)
static String itemProperty(Item item, String name) {
// name contains the actual property name accessed
return switch(name) {
case "foo" -> item.getFoo();
case "bar" -> item.getBar();
default -> null;
};
}Template usage:
{item.foo}
{item.bar}
{item.anything}Use matchRegex to match property names against a regex pattern. Requires an additional String parameter:
@TemplateExtension(matchRegex = "\\d{1,10}")
static String getByIndex(List<String> list, String index) {
return list.get(Integer.parseInt(index));
}Template usage:
{list.0}
{list.5}
{list.42}Extensions with higher priority take precedence when multiple extensions match:
@TemplateExtension(priority = 10)
static String highPriority(Item item) {
return "high priority";
}
@TemplateExtension(priority = 1)
static String lowPriority(Item item) {
return "low priority";
}Priority reference:
| Priority | Usage |
|---|---|
| 10+ | Type-safe expressions maximum priority |
| 5 | Default extension priority |
| 1 | Standard resolver priority |
| -1 | Built-in reflection resolver |
Extensions can define namespace resolvers for global functions not tied to a base object:
@TemplateExtension(namespace = "util")
public class UtilExtensions {
static String format(String pattern, Object... args) {
return String.format(pattern, args);
}
}Template usage:
{util:format("Hello %s", name)}Multiple methods can share the same namespace within a class, but different classes cannot share namespaces.
Use @TemplateAttribute to inject attributes from the template instance:
@TemplateExtension
static String localizedPrice(Item item,
@TemplateAttribute Locale locale) {
return NumberFormat.getCurrencyInstance(locale)
.format(item.getPrice());
}Template usage:
template.data("item", item)
.setAttribute("locale", Locale.FRANCE)
.render();{item.localizedPrice}Apply @TemplateExtension to a class to generate resolvers for all eligible methods:
@TemplateExtension
public class StringExtensions {
static String upper(String str) {
return str.toUpperCase();
}
static String lower(String str) {
return str.toLowerCase();
}
}Method-level annotations override class-level settings.
The StringTemplateExtensions class provides string formatting, concatenation, and the str: namespace.
Format a string using String.format() syntax:
@TemplateExtension(matchNames = {"fmt", "format"}, priority = 2)
static String fmtInstance(String format, String ignoredPropertyName,
Object... args)
@TemplateExtension(matchNames = {"fmt", "format"}, priority = 3)
static String fmtInstance(String format, String ignoredPropertyName,
Locale locale, Object... args)Template usage:
{pattern.fmt(name, age)}
{"Hello %s, you are %d".fmt(name, age)}
{"Total: %.2f".format(total)}
{"Date: %1$tB %1$te, %1$tY".fmt(locale, date)}Concatenate values to a string:
@TemplateExtension(matchName = "+")
static String plus(String str, Object val)Template usage:
{firstName + " " + lastName}
{"Hello " + name}The str: namespace provides global string utilities.
Format strings using the namespace syntax:
@TemplateExtension(namespace = "str", matchNames = {"fmt", "format"},
priority = 2)
static String fmt(String ignoredPropertyName, String format,
Object... args)
@TemplateExtension(namespace = "str", matchNames = {"fmt", "format"},
priority = 3)
static String fmt(String ignoredPropertyName, Locale locale,
String format, Object... args)Template usage:
{str:fmt("Hello %s", name)}
{str:format("Total: $%.2f", amount)}
{str:fmt(locale, "Date: %1$tB %1$te, %1$tY", date)}Concatenate multiple arguments into a single string:
@TemplateExtension(namespace = "str", priority = 1)
static String concat(Object... args)Template usage:
{str:concat("Hello", " ", firstName, " ", lastName)}
{str:concat(prefix, name, suffix)}Join multiple arguments with a delimiter:
@TemplateExtension(namespace = "str", priority = 0)
static String join(String delimiter, Object... args)Template usage:
{str:join(", ", firstName, middleName, lastName)}
{str:join("_", "a", "b", "c")}
{str:join(" | ", item1, item2, item3)}Create a StringBuilder instance:
@TemplateExtension(namespace = "str", priority = -1)
static StringBuilder builder()
@TemplateExtension(namespace = "str", priority = -2)
static StringBuilder builder(Object val)Template usage:
{#let sb = str:builder}
{sb + "Hello"}
{sb + " World"}
{sb}
{/let}
{#let sb = str:builder("Initial")}
{sb + " value"}
{/let}Access string literals via the namespace (matches any name with lowest priority):
@TemplateExtension(namespace = "str", priority = -10, matchName = ANY)
static String self(String name)Template usage:
{str:['Literal string']}
{str:['Any text here']}
{str:foo}Append to a StringBuilder:
@TemplateExtension(matchName = "+")
static StringBuilder plus(StringBuilder builder, Object val)Template usage:
{#let sb = str:builder}
{sb + "Hello"}
{sb + " "}
{sb + name}
{sb}
{/let}The CollectionTemplateExtensions class provides methods for working with lists and collections.
Get an element at a specific index:
@TemplateExtension
static <T> T get(List<T> list, int index)Template usage:
{list.get(0)}
{list.get(5)}
{items.get(idx)}Access list elements using numeric property names:
@TemplateExtension(matchRegex = "\\d{1,10}")
static <T> T getByIndex(List<T> list, String index)Template usage:
{list.0}
{list.1}
{items.42}Matches property names that are 1-10 digit numbers.
Get a reverse iterator for the list:
@TemplateExtension
static <T> Iterator<T> reversed(List<T> list)Template usage:
{#for item in list.reversed}
{item.name}
{/for}Get the first n elements:
@TemplateExtension
static <T> List<T> take(List<T> list, int n)Template usage:
{#for item in items.take(5)}
{item.name}
{/for}Throws IndexOutOfBoundsException if n < 1 or n > list.size().
Get the last n elements:
@TemplateExtension
static <T> List<T> takeLast(List<T> list, int n)Template usage:
{#for item in items.takeLast(3)}
{item.name}
{/for}Throws IndexOutOfBoundsException if n < 1 or n > list.size().
Return an empty list if the collection is null:
@TemplateExtension
static <T> Collection<T> orEmpty(Collection<T> iterable)Template usage:
{#for item in items.orEmpty}
{item.name}
{/for}This extension has higher priority than the built-in ValueResolvers.orEmpty() and enables validation of expressions derived from {list.orEmpty}.
Get the first element:
@TemplateExtension
static <T> T first(List<T> list)Template usage:
{items.first.name}
{list.first}Throws NoSuchElementException if the list is empty.
Get the last element:
@TemplateExtension
static <T> T last(List<T> list)Template usage:
{items.last.name}
{list.last}Throws NoSuchElementException if the list is empty.
The MapTemplateExtensions class provides methods for working with maps.
Access map properties and entries using a catch-all resolver:
@TemplateExtension(matchName = ANY)
static Object map(Map map, String name)Special property names:
keys or keySet - Returns map.keySet()values - Returns map.values()entrySet - Returns map.entrySet()size - Returns map.size()empty or isEmpty - Returns map.isEmpty()For other names: Attempts map.get(name) and returns Results.NotFound if the key doesn't exist and the value is null.
Template usage:
{map.keys}
{map.values}
{map.size}
{map.isEmpty}
{map.myKey}
{map.customProperty}Explicitly get a value by key:
@TemplateExtension
static <V> V get(Map<?, V> map, Object key)Template usage:
{map.get('someKey')}
{map.get(keyVariable)}
{data.get('my-key-with-dashes')}Check if a map contains a key:
@TemplateExtension
static boolean containsKey(Map<?, ?> map, Object key)Template usage:
{#if map.containsKey('foo')}
Found foo: {map.foo}
{/if}
{map.containsKey(dynamicKey)}The NumberTemplateExtensions class provides arithmetic operations for numbers.
Calculate the modulo of two integers:
@TemplateExtension
static Integer mod(Integer number, Integer mod)Template usage:
{count.mod(2)}
{index.mod(10)}
{#if count.mod(2) == 0}
Even number
{/if}Add integers and longs:
@TemplateExtension(matchNames = {"plus", "+"})
static Integer addToInt(Integer number, String name, Integer other)
@TemplateExtension(matchNames = {"plus", "+"})
static Long addToInt(Integer number, String name, Long other)
@TemplateExtension(matchNames = {"plus", "+"})
static Long addToLong(Long number, String name, Integer other)
@TemplateExtension(matchNames = {"plus", "+"})
static Long addToLong(Long number, String name, Long other)Template usage:
{count.plus(5)}
{count + 5}
{total + adjustment}
{price.plus(tax)}Type conversions:
Integer + Integer → IntegerInteger + Long → LongLong + Integer → LongLong + Long → LongSubtract integers and longs:
@TemplateExtension(matchNames = {"minus", "-"})
static Integer subtractFromInt(Integer number, String name, Integer other)
@TemplateExtension(matchNames = {"minus", "-"})
static Long subtractFromInt(Integer number, String name, Long other)
@TemplateExtension(matchNames = {"minus", "-"})
static Long subtractFromLong(Long number, String name, Integer other)
@TemplateExtension(matchNames = {"minus", "-"})
static Long subtractFromLong(Long number, String name, Long other)Template usage:
{count.minus(3)}
{count - 3}
{total - discount}
{price.minus(reduction)}Type conversions:
Integer - Integer → IntegerInteger - Long → LongLong - Integer → LongLong - Long → LongThe TimeTemplateExtensions class provides date/time formatting using the time: namespace and instance methods. Formatters are cached for performance.
Format temporal objects directly:
@TemplateExtension
static String format(TemporalAccessor temporal, String pattern)
@TemplateExtension
static String format(TemporalAccessor temporal, String pattern,
Locale locale)
@TemplateExtension
static String format(TemporalAccessor temporal, String pattern,
Locale locale, ZoneId timeZone)Template usage:
{now.format('yyyy-MM-dd')}
{timestamp.format('HH:mm:ss')}
{date.format('MMMM dd, yyyy', locale)}
{instant.format('yyyy-MM-dd HH:mm:ss', locale, timezone)}Format various date/time objects via the namespace:
@TemplateExtension(namespace = "time")
static String format(Object dateTimeObject, String pattern)
@TemplateExtension(namespace = "time")
static String format(Object dateTimeObject, String pattern, Locale locale)
@TemplateExtension(namespace = "time")
static String format(Object dateTimeObject, String pattern,
Locale locale, ZoneId timeZone)Supported object types:
TemporalAccessor (LocalDateTime, Instant, ZonedDateTime, etc.)java.util.Datejava.util.CalendarNumber (interpreted as epoch milliseconds)Template usage:
{time:format(now, 'yyyy-MM-dd')}
{time:format(date, 'MMMM dd, yyyy')}
{time:format(timestamp, 'HH:mm:ss', locale)}
{time:format(instant, 'yyyy-MM-dd HH:mm:ss', locale, timezone)}
{time:format(1234567890000, 'yyyy-MM-dd HH:mm:ss')}Pattern examples:
{time:format(now, 'yyyy-MM-dd')} <!-- 2024-03-15 -->
{time:format(now, 'HH:mm:ss')} <!-- 14:30:45 -->
{time:format(now, 'EEEE, MMMM dd, yyyy')} <!-- Friday, March 15, 2024 -->
{time:format(now, 'yyyy-MM-dd HH:mm:ss')} <!-- 2024-03-15 14:30:45 -->
{time:format(now, "dd MMM yyyy")} <!-- 15 Mar 2024 -->Clear the formatter cache (for testing or memory management):
public static void clearCache()Formatters are cached by pattern, locale, and timezone combination for optimal performance.
The ConfigTemplateExtensions class provides access to MicroProfile Config properties via the config: namespace.
Access any configuration property directly:
@TemplateExtension(namespace = "config", matchName = ANY)
static Object getConfigProperty(String propertyName)Template usage:
{config:appName}
{config:['my.dotted.property']}
{config:version}Get a string property explicitly:
@TemplateExtension(namespace = "config", priority = DEFAULT_PRIORITY + 1)
static Object property(String propertyName)Template usage:
{config:property('app.name')}
{config:property('database.url')}Returns Results.NotFound if the property doesn't exist.
Get a boolean property:
@TemplateExtension(namespace = "config", priority = DEFAULT_PRIORITY + 2,
matchName = "boolean")
static Object booleanProperty(String propertyName)Template usage:
{#if config:boolean('feature.enabled')}
Feature is enabled
{/if}
{config:boolean('debug.mode')}Returns Results.NotFound if the property doesn't exist.
Get an integer property:
@TemplateExtension(namespace = "config", priority = DEFAULT_PRIORITY + 2,
matchName = "integer")
static Object integerProperty(String propertyName)Template usage:
{config:integer('timeout.seconds')}
{config:integer('max.connections')}
{#if config:integer('retry.count') > 3}
Too many retries
{/if}Returns Results.NotFound if the property doesn't exist.
The ObjectsTemplateExtensions class provides equality comparison methods.
Compare two objects for equality using Objects.equals():
@TemplateExtension(matchNames = {"eq", "==", "is"})
static boolean eq(Object value, String ignoredName, Object other)Template usage:
{item.eq(otherItem)}
{item == otherItem}
{item is otherItem}
{#if status.eq('active')}
Active
{/if}
{#if user == currentUser}
This is you
{/if}
{#if value is null}
No value
{/if}The OrOperatorTemplateExtensions class provides the OR fallback operator.
Return a fallback value if the first value is null, not found, or an empty Optional:
@TemplateExtension
static <T> T or(T value, T other)Template usage:
{name.or('Anonymous')}
{user.email.or('no-email@example.com')}
{optionalValue.or(defaultValue)}
{#for item in list.or(emptyList)}
{item}
{/for}Fallback conditions:
nullResults.NotFoundOptionalWhen multiple extensions match the same expression, Qute uses priority to determine which to apply:
Priority levels:
10 - Maximum for type-safe expressions5 - Default for @TemplateExtension1 - Higher priority custom extensions0 and below - Lower priority extensions-1 - Reflection-based resolverPriority considerations:
Example with multiple matching extensions:
@TemplateExtension(priority = 10)
static String format(String str) {
return "high priority: " + str;
}
@TemplateExtension(priority = 1)
static String format(String str) {
return "low priority: " + str;
}The high-priority extension will be used when {str.format} is encountered.
@TemplateExtension
public class ProductExtensions {
// Simple property
static BigDecimal discountedPrice(Product product) {
return product.getPrice().multiply(new BigDecimal("0.9"));
}
// Method with parameter
static BigDecimal priceWithDiscount(Product product, int percent) {
BigDecimal discount = BigDecimal.valueOf(100 - percent)
.divide(BigDecimal.valueOf(100));
return product.getPrice().multiply(discount);
}
// Formatted output
@TemplateExtension(matchName = "priceFormatted")
static String formatPrice(Product product,
@TemplateAttribute Locale locale) {
return NumberFormat.getCurrencyInstance(locale)
.format(product.getPrice());
}
}Template usage:
<p>Original: {product.price}</p>
<p>Discounted: {product.discountedPrice}</p>
<p>10% off: {product.priceWithDiscount(10)}</p>
<p>Formatted: {product.priceFormatted}</p>@TemplateExtension
public class MathExtensions {
@TemplateExtension(namespace = "math")
static double sqrt(Number value) {
return Math.sqrt(value.doubleValue());
}
@TemplateExtension(namespace = "math")
static int abs(int value) {
return Math.abs(value);
}
@TemplateExtension(namespace = "math")
static long max(long a, long b) {
return Math.max(a, b);
}
@TemplateExtension(namespace = "math")
static long min(long a, long b) {
return Math.min(a, b);
}
}Template usage:
{math:sqrt(144)}
{math:abs(-42)}
{math:max(count1, count2)}
{math:min(limit, available)}@TemplateExtension
public class EnhancedCollectionExtensions {
// Check if collection contains element
static <T> boolean contains(Collection<T> collection, T element) {
return collection.contains(element);
}
// Get collection as comma-separated string
static <T> String joinWithComma(Collection<T> collection) {
return collection.stream()
.map(Object::toString)
.collect(Collectors.joining(", "));
}
// Filter by predicate (example for simple conditions)
@TemplateExtension(matchName = "notEmpty")
static <T> List<T> filterNotEmpty(List<T> list) {
return list.stream()
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}Template usage:
{#if items.contains(selectedItem)}
Selected item is in the list
{/if}
<p>Items: {items.joinWithComma}</p>
{#for item in items.notEmpty}
{item}
{/for}ANY and regex matchersTimeTemplateExtensions formatter cache@TemplateAttribute sparingly - Only for truly context-dependent behavior// Wrong: First parameter should be the base object type
@TemplateExtension
static String format(String pattern, MyObject obj) { // Wrong order!
return String.format(pattern, obj.getValue());
}
// Correct: Base object is first parameter
@TemplateExtension
static String format(MyObject obj, String pattern) {
return String.format(pattern, obj.getValue());
}
// Usage: {myObject.format('Value: %s')}// Wrong: matchNames without name parameter
@TemplateExtension(matchNames = {"plus", "+"})
static Integer add(Integer number, Integer other) { // Missing name param!
return number + other;
}
// Correct: Add String parameter for name
@TemplateExtension(matchNames = {"plus", "+"})
static Integer add(Integer number, String name, Integer other) {
return number + other;
}// Wrong: Same namespace in different classes
@TemplateExtension(namespace = "util")
public class UtilExtensions1 {
static String foo() { return "foo"; }
}
@TemplateExtension(namespace = "util")
public class UtilExtensions2 { // Conflict!
static String bar() { return "bar"; }
}
// Correct: Different namespaces or same class
@TemplateExtension(namespace = "util")
public class UtilExtensions {
static String foo() { return "foo"; }
static String bar() { return "bar"; }
}// Wrong: Doesn't handle null
@TemplateExtension
static String upper(String str) {
return str.toUpperCase(); // NullPointerException if str is null!
}
// Correct: Handle null gracefully
@TemplateExtension
static String upper(String str) {
return str != null ? str.toUpperCase() : "";
}@TemplateExtension
public class ConditionalExtensions {
// Only applies in debug mode
static String debug(Object obj, @TemplateAttribute("debug") Boolean debugMode) {
if (Boolean.TRUE.equals(debugMode)) {
return obj.getClass().getName() + ": " + obj.toString();
}
return obj.toString();
}
}
// Usage
template.setAttribute("debug", true);
// Template: {myObject.debug}@TemplateExtension
public class ChainableExtensions {
// Returns same type for chaining
static List<String> filterEmpty(List<String> list) {
return list.stream()
.filter(s -> s != null && !s.isEmpty())
.collect(Collectors.toList());
}
static List<String> sort(List<String> list) {
return list.stream()
.sorted()
.collect(Collectors.toList());
}
}
// Template: Chain multiple extensions
// {items.filterEmpty.sort}@TemplateExtension
public class GenericExtensions {
// Works with any List<T>
static <T> int count(List<T> list, T element) {
return (int) list.stream()
.filter(item -> Objects.equals(item, element))
.count();
}
// Works with any Collection<T>
static <T> List<T> distinct(Collection<T> collection) {
return collection.stream()
.distinct()
.collect(Collectors.toList());
}
}@TemplateExtension(namespace = "str")
static String concat(Object... args) {
// Varargs allows any number of arguments
return Arrays.stream(args)
.map(String::valueOf)
.collect(Collectors.joining());
}Template usage:
{str:concat("a")} <!-- Single arg -->
{str:concat("a", "b", "c")} <!-- Multiple args -->
{str:concat()} <!-- No args (empty array) -->// Returns primitive - autoboxed for template use
@TemplateExtension
static int length(String str) {
return str.length();
}
// Returns null - renders as empty or "null" depending on config
@TemplateExtension
static String nullReturning(String str) {
return null;
}
// Returns collection - can be iterated in template
@TemplateExtension
static List<Character> chars(String str) {
return str.chars()
.mapToObj(c -> (char) c)
.collect(Collectors.toList());
}@TemplateExtension
static String formatted(Product product,
@TemplateAttribute("locale") Locale locale,
@TemplateAttribute("currency") String currency,
@TemplateAttribute("debug") Boolean debug) {
String formatted = formatPrice(product.getPrice(), currency, locale);
if (Boolean.TRUE.equals(debug)) {
formatted += " [" + product.getClass().getName() + "]";
}
return formatted;
}// Match only 2-digit numbers
@TemplateExtension(matchRegex = "\\d{2}")
static String getTwoDigit(List<String> list, String index) {
return list.get(Integer.parseInt(index));
}
// Match properties starting with "get"
@TemplateExtension(matchRegex = "get[A-Z].*")
static Object dynamicGetter(MyObject obj, String name) {
// Extract property name after "get"
String propertyName = name.substring(3);
return obj.getProperty(propertyName);
}
// Match hex color codes
@TemplateExtension(matchRegex = "#[0-9a-fA-F]{6}")
static Color parseColor(String base, String hex) {
return Color.decode(hex);
}@TemplateExtension
static String repeat(String str, int times) {
return str.repeat(times);
}
// In template - integer is passed directly
// {text.repeat(3)}
// What if expression evaluates to Long or String?
// Qute will attempt type coercion:
// - Long → int (if in range)
// - String → int (parsing, may fail)
// - Double → int (truncation)
// For safety, validate parameter types:
@TemplateExtension
static String safeRepeat(String str, Object times) {
if (times instanceof Number) {
int count = ((Number) times).intValue();
return str.repeat(count);
}
throw new IllegalArgumentException("times must be a number");
}@TemplateExtension(priority = 5) // Class-level default
public class MyExtensions {
// Uses class-level priority (5)
static String method1(String str) {
return str;
}
// Overrides class-level priority
@TemplateExtension(priority = 10)
static String method2(String str) {
return str;
}
// Uses class-level priority (5)
static String method3(String str) {
return str;
}
}Template extensions provide a powerful mechanism for enhancing Qute templates with custom functionality:
The built-in extensions provide over 40 methods across 8 extension classes, covering the most common template operations. Custom extensions follow the same patterns to add domain-specific functionality.