Releases: ponylang/templates
0.3.2
Add sink/visitor interface for split rendering
Both Template and HtmlTemplate now support two new rendering methods in addition to render():
render_split(values) returns (Array[String] val, Array[String] val) — the static literal segments and dynamic value segments as separate arrays. For N dynamic insertions, the statics array has N+1 entries. This enables use cases like tagged template literals or any context where static template structure and dynamic values need to be handled differently.
render_to(sink, values) drives a caller-supplied TemplateSink with alternating literal and dynamic_value calls. The interleaving is strict: calls always start and end with literal, and for N dynamics there are exactly N+1 literal calls (empty strings are inserted where needed). Control flow subtrees (if, ifnot, for) collapse into a single dynamic_value call; block is transparent.
For HtmlTemplate, dynamic values passed to the sink are already escaped based on HTML context — the sink receives final, safe strings.
// render_split: separate statics from dynamics
let t = Template.parse("Hello {{ name }}, you have {{ count }} messages.")?
let v = TemplateValues
v("name") = "Alice"
v("count") = "3"
(let statics, let dynamics) = t.render_split(v)?
// statics: ["Hello ", ", you have ", " messages."]
// dynamics: ["Alice", "3"]
// render_to: drive a custom sink
class MySink is TemplateSink
fun ref literal(text: String) => // handle static text
fun ref dynamic_value(value: String) => // handle dynamic value
let sink: MySink ref = MySink
t.render_to(sink, v)?[0.3.2] - 2026-03-14
Added
- Add sink/visitor interface for split rendering (PR #83)
0.3.1
Add public scope() method to TemplateValues
TemplateValues now has a scope() method that creates an empty writable child scope backed by the receiver as a read-only parent. Writes go to the child; lookups that miss in the child fall through to the parent.
let parent = TemplateValues
parent("name") = "Alice"
let child = parent.scope()
child("extra") = "new value"
// child sees both its own values and the parent's
child("extra")?.string()? // => "new value"
child("name")?.string()? // => "Alice"This is useful when you need a writable TemplateValues that inherits existing assigns without copying data or modifying the original. For example, composing component HTML into a template when the backing values are seen as box through viewpoint adaptation.
[0.3.1] - 2026-03-13
Added
- Add public
scope()method to TemplateValues (PR #77)
0.3.0
Add else and elseif branches to if blocks
if blocks now support else and elseif branches, so you no longer need to duplicate content with negated conditions.
let t = Template.parse(
"{{ if admin }}admin panel" +
"{{ elseif member }}member area" +
"{{ else }}public page{{ end }}")?elseif chains can be as long as needed. A final else branch is optional and renders when no condition matches.
if checks both existence and non-emptiness: a variable bound to an empty sequence is falsy, so {{ if items }} naturally guards a loop without a separate check.
Add ifnot negated conditional blocks
ifnot renders its body when a variable is absent or is an empty sequence — the logical inverse of if. This lets you write the "missing" case as the primary branch instead of requiring an if/else just to get at the else.
let t = Template.parse(
"{{ ifnot name }}Anonymous{{ end }}")?Like if, ifnot supports else and elseif branches:
let t = Template.parse(
"{{ ifnot name }}Anonymous{{ else }}{{ name }}{{ end }}")?Unify if and ifnotempty conditionals
ifnotempty has been removed. if now checks both existence and sequence non-emptiness, making ifnotempty redundant.
Before:
let t = Template.parse(
"{{ ifnotempty items }}{{ for i in items }}{{ i }}{{ end }}{{ end }}")?After:
let t = Template.parse(
"{{ if items }}{{ for i in items }}{{ i }}{{ end }}{{ end }}")?Unlike ifnotempty, if supports else and elseif branches:
let t = Template.parse(
"{{ if items }}{{ for i in items }}{{ i }}{{ end }}" +
"{{ else }}no items{{ end }}")?Add include partials
Templates can now reuse shared fragments via {{ include "name" }}. Partials are raw template strings registered in TemplateContext and inlined at parse time. They share the calling template's variable scope and can contain any block type (variables, loops, conditionals). Circular includes are detected at parse time.
let ctx = TemplateContext(where partials' =
recover val
let p = Map[String, String]
p("header") = "=== {{ title }} ==="
p
end
)
let template = Template.parse(
"{{ include \"header\" }}\n{{ for item in items }}{{ item }}\n{{ end }}",
ctx)?Partial names may contain letters, digits, underscores, and hyphens ([a-zA-Z0-9_-]+).
Add template inheritance
Templates can now extend a base layout and override named blocks. A base template defines {{ block name }}...{{ end }} sections with default content. A child template declares {{ extends "base" }} as its first statement and overrides specific blocks — blocks not overridden render their defaults from the base.
let ctx = TemplateContext(where partials' =
recover val
let p = Map[String, String]
p("base") =
"<head>{{ block head }}<title>Default</title>{{ end }}</head>" +
"<body>{{ block content }}{{ end }}</body>"
p
end
)
let template = Template.parse(
"{{ extends \"base\" }}" +
"{{ block head }}<title>{{ title }}</title>{{ end }}" +
"{{ block content }}<h1>{{ title }}</h1>{{ end }}",
ctx)?Base templates are registered as partials via TemplateContext — the same mechanism used by include. Multi-level inheritance works naturally (child extends parent extends grandparent). Content outside {{ block }} definitions in a child is silently ignored. Circular extends chains are detected at parse time.
Add default values for missing variables
Missing variables in templates render as empty strings, with no way to provide a fallback. | default("...") lets template authors declare what to show when a variable is absent.
let t = Template.parse(
"Hello {{ name | default(\"stranger\") }}")?
// name is missing — renders "Hello stranger"
t.render(TemplateValues)?
// name is present — renders "Hello Alice"
let v = TemplateValues
v("name") = "Alice"
t.render(v)?Defaults work with dotted properties and can be chained with other filters:
// Dotted property with default
Template.parse("{{ user.name | default(\"anon\") }}")?
// Default chained with upper
Template.parse("{{ name | default(\"anon\") | upper }}")?Defaults are not allowed in control flow conditions (if, ifnot, elseif, for ... in) — pipes are only valid in expression positions.
Add trim syntax for whitespace control
Templates now support {{- and -}} trim markers that strip whitespace from adjacent literals. {{- removes trailing whitespace (spaces, tabs, newlines) from the text before the tag, -}} removes leading whitespace from the text after it. Use both together with {{- ... -}} to strip in both directions.
This matters most when generating indentation-sensitive output like YAML or Python. Without trimming, control flow tags (if, for, end) inject blank lines and leading whitespace into the output because the newlines around the tags themselves become part of the rendered text. Trim markers let you eliminate that whitespace without cramming everything onto one line.
let values = TemplateValues
values("name") = "app"
values("services") = TemplateValue(
recover val
let s = Array[TemplateValue]
s.push(TemplateValue("web"))
s.push(TemplateValue("db"))
s
end)
let t = Template.parse(
"name: {{ name }}\n" +
"services:\n" +
"{{ for svc in services -}}" +
"\n- {{ svc }}\n" +
"{{ end }}")?
t.render(values)?
// name: app
// services:
// - web
// - dbThe right-trim on the for tag strips the newline that would otherwise appear before the first list item. Either marker can be used independently — you pick which side to trim based on where the unwanted whitespace is.
Replace function calls with filter pipes
Function calls ({{ fn(arg) }}) have been replaced by a filter/pipe system. Values are now transformed by piping them through one or more filters: {{ value | filter1 | filter2 }}. The pipe source can be a template variable or a string literal: {{ "hello" | upper }}.
Seven built-in filters are available in all templates without registration:
upper— uppercaselower— lowercasetrim— strip leading/trailing whitespacecapitalize— first character upper, rest lowertitle— title case each worddefault("fallback")— use fallback when the value is empty or missingreplace("old", "new")— replace all occurrences
Before:
let ctx = TemplateContext(
recover
let functions = Map[String, {(String): String}]
functions("upper") = {(s) =>
let out = s.clone()
out.upper_in_place()
consume out
}
functions
end
)
let t = Template.parse("{{ upper(name) }}", ctx)?After:
// upper is built-in — no registration needed
let t = Template.parse("{{ name | upper }}")?Filters can be chained left-to-right and accept string literal or variable arguments:
Template.parse("{{ name | default(\"anon\") | upper }}")?
// String literal as pipe source — no variable needed
Template.parse("{{ \"hello world\" | upper }}")?The TemplateContext constructor's functions parameter has been replaced by filters. Custom filters implement Filter (0 extra args), Filter2 (1 extra arg), or Filter3 (2 extra args):
Before:
let ctx = TemplateContext(
recover
let functions = Map[String, {(String): String}]
functions("yell") = {(s) => s.upper()}
functions
end,
partials
)After:
let ctx = TemplateContext(
recover val
let filters = Map[String, AnyFilter]
filters("yell") = Upper // reuse built-in, or provide a custom primitive
filters
end,
partials
)Filters are validated at parse time — unknown filter names and arity mismatches produce parse errors rather than runtime failures.
Add template comments
Templates now support comments with {{! ... }} syntax. Everything between ! and }} is discarded during rendering, so comments never appear in the output.
let t = Template.parse("Hello {{! a comment }} world")?
t.render(TemplateValues)? // => "Hello world"Trim markers work with comments the same way they work with any other block type:
Template.parse("Hello{{-! trimmed -}}world")?
.render(TemplateValues)? // => "Helloworld"Comments can appear anywhere a normal block can: inside if/else bodies, loop bodies, and before extends declarations. They are transparent to the template engine — a comment before {{ extends "base" }} does not prevent the extends from being recognized as the first statement.
Add raw / literal blocks
Raw blocks let you output literal {{ }} delimiters without the engine interpreting them. Wrap content in {{raw}}...{{end}} and everything between the tags is emitted as-is:
let t = Template.parse("{{raw}}{{ name }}{{end}}")?
t.render(TemplateValues)? // => "{{ name }}"Trim markers work on both tags ({{- raw -}}...{{- end -}}), following the same rules as other block types.
The first {{ end }} always closes the raw block, so literal {{ end }} cannot appear inside raw content. This is the same class of limitation as }} inside comments.
Add HTML-aware template engine with contextual auto-escaping
HtmlTemplate works like Template but automatically escapes variable output based on HTML context. A variable inside a <p> tag gets HTML entity escaping; inside an href attribute it gets URL scheme filtering and percent-encoding; inside an onclick attribute it gets JavaScript string escaping; and so on.
let t = HtmlTemplate.parse(
"<h1>{{ title }}</h1>\n<p>{{ message }}</p>")?
let v = TemplateValues
v("title") = "Hello & welcome"
v("message") = "<script>alert('xss')</script>"
t.render(v)?
// <h1>Hello & welcome</h1>
// <p><script>alert('xss')</script></p>Parse-time validation rejects templates with variables in structurally invalid positions ...
0.2.4
0.2.3
Update to work with Pony 0.49.0
Pony 0.49.0 introduced a lot of different breaking changes. We've updated to account for them all.
[0.2.3] - 2022-02-26
Fixed
- Update to work with Pony 0.49.0 (PR #14)
0.2.2
Update to work with latest ponyc
The most recent ponyc implements RFC #65 which changes the type of Env.root.
We've updated accordingly. You won't be able to use this and future versions of the library without a corresponding update to your ponyc version.
[0.2.2] - 2022-01-16
Fixed
- Update to work with Pony 0.46.0 (PR #12)
0.2.1
0.2.0
0.1.0
Initial release