trellis 0.4.1
trellis: ^0.4.1 copied to clipboard
Template engine for Dart, using natural HTML templates. Fragment-first for hypermedia-driven frameworks like HTMX. Inspired by Thymeleaf.
A natural HTML template engine for Dart — templates are valid HTML that browsers render as prototypes without a server. Fragment-first design built for hypermedia-driven web frameworks like HTMX. Inspired by Thymeleaf.
Features #
- Natural templates -- valid HTML that browsers render as prototypes without a server
- Fragment-first --
tl:fragment+renderFragment()maps directly to HTMX partial responses - Full expression language -- variables, arithmetic, literal substitution, selection, URL, ternary, Elvis, comparisons, boolean
- i18n message expressions --
#{key}withMessageSource, parameterized messages, and locale support - Filter arguments --
| filterName:arg1:arg2parameterized filter syntax - Switch/case, block, remove, inline -- multi-branch conditionals, virtual elements, output control, inline JS/CSS processing
- Parameterized fragments --
tl:fragment="card(title, body)"with argument passing at inclusion - CSS selector targeting --
tl:insert="~{file :: #id}"andtl:insert="~{file :: .class}" - Custom processors & dialects -- register
Processorimplementations; compose feature sets withDialect - Sync-first API --
render()is synchronous;renderFile()is async only for I/O - LRU DOM cache -- configurable size,
CacheStatsfor hit/miss metrics - Strict mode -- undefined variables/members throw
ExpressionException - Secure --
FileSystemLoaderenforces path traversal and symlink escape protection - AOT-compatible -- context is
Map<String, dynamic>, no reflection
Requirements #
- Dart SDK
^3.10.0
Installation #
dart pub add trellis
Quick Start #
import 'package:trellis/trellis.dart';
final engine = Trellis();
final html = engine.render(
'<h1 tl:text="${title}">Default Title</h1>',
{'title': 'Hello, Trellis!'},
);
// <h1>Hello, Trellis!</h1>
Template Syntax Reference #
Text Substitution #
<!-- Escaped text (safe from XSS) -->
<p tl:text="${message}">placeholder</p>
<!-- Unescaped HTML (use with trusted content only) -->
<div tl:utext="${richContent}">placeholder</div>
Conditionals #
<div tl:if="${user}">Welcome back!</div>
<div tl:unless="${loggedIn}">Please log in.</div>
Truthy: non-null, non-false, non-zero, not "false"/"off"/"no". Empty strings and empty lists are truthy.
Switch / Case
<div tl:switch="${role}">
<p tl:case="admin">Admin view</p>
<p tl:case="user">User view</p>
<p tl:case="*">Guest view</p>
</div>
Iteration #
<li tl:each="item : ${items}" tl:text="${item}">placeholder</li>
Status variables are available as ${itemStat} (or custom name via item, stat : ${items}):
| Variable | Description |
|---|---|
index |
0-based index |
count |
1-based count |
size |
Total number of items |
first |
true for first item |
last |
true for last item |
odd |
true for 0-based odd indices |
even |
true for 0-based even indices |
current |
Current item value |
Fragments #
<!-- Define a fragment (parameterized) -->
<div tl:fragment="card(title, body)">
<h2 tl:text="${title}">Title</h2>
<p tl:text="${body}">Body</p>
</div>
<!-- Include a fragment (keeps host element) -->
<div tl:insert="card('Hello', 'World')">replaced by fragment</div>
<!-- Replace with fragment (replaces host element) -->
<div tl:replace="userCard">replaced entirely</div>
<!-- Cross-file inclusion -->
<div tl:insert="~{components :: header}">loads header from components.html</div>
<!-- CSS selector targeting -->
<div tl:insert="~{components :: #main-nav}">loads by id</div>
Circular fragment inclusions are detected and reported with the full cycle path.
Local Variables #
<div tl:with="fullName=${first} + ' ' + ${last}">
<span tl:text="${fullName}">Name</span>
</div>
Object / Selection #
<!-- Set object context; *{} accesses fields directly -->
<div tl:object="${user}">
<span tl:text="*{name}">Name</span>
<span tl:text="*{email}">Email</span>
</div>
Objects are accessed via toMap() or toJson() if present, otherwise as Map<String, dynamic>.
Attribute Setting #
<!-- Shorthand attributes -->
<a tl:href="${url}">link</a>
<img tl:src="${imageUrl}">
<input tl:value="${val}">
<div tl:class="${className}">styled</div>
<div tl:id="${elementId}">identified</div>
<!-- Append to existing class/style -->
<div class="card" tl:classappend="${active} ? 'active' : ''">content</div>
<div style="color:red" tl:styleappend="font-weight:bold">content</div>
<!-- Generic attribute setting -->
<div tl:attr="data-id=${item.id},title=${item.name}">content</div>
tl:class replaces the existing class (not appends). tl:classappend/tl:styleappend append. Null values remove the attribute.
Boolean HTML attributes (disabled, checked, etc.): true renders valueless, false removes.
Block (Virtual Element) #
<!-- tl:block renders only its children — the host element is not emitted -->
<tl:block tl:each="item : ${items}">
<dt tl:text="${item.key}">key</dt>
<dd tl:text="${item.value}">value</dd>
</tl:block>
<!-- Self-closing form also supported -->
<tl:block tl:utext="${bodyHtml}"/>
Remove #
<div tl:remove="all">removed entirely from output</div>
<div tl:remove="body">tag kept, children removed</div>
<div tl:remove="tag">children kept, tag removed</div>
<ul tl:remove="all-but-first"><li>kept</li><li tl:remove="all">removed</li></ul>
<div tl:remove="none">kept as-is (prototype marker)</div>
Inline Processing #
<!-- Enable inline expressions in element text content -->
<p tl:inline="text">Hello, [[${name}]]! Today is [(${rawHtml})].</p>
<!-- Inline in script blocks -->
<script tl:inline="javascript">
var user = [[${user.name}]];
</script>
<!-- Inline in style blocks -->
<style tl:inline="css">
.alert { color: [[${alertColor}]]; }
</style>
[[${expr}]] — escaped output. [(${expr})] — unescaped output.
Filters #
Filters transform expression values using pipe syntax:
<span tl:text="${name | upper}">NAME</span>
<span tl:text="${input | trim | lower}">cleaned</span>
Built-in filters: upper, lower, trim, length.
Filters accept arguments using colon-separated syntax. Argument types supported: string (single-quoted, with \' escape), int, double, bool, null, and bare identifiers.
<span tl:text="${price | currency:'USD':2}">price</span>
<span tl:text="${text | truncate:100}">long text</span>
Custom filters via constructor:
Trellis(filters: {
'currency': (v) => '\$${(v as num).toStringAsFixed(2)}',
// Filter with arguments: FilterFunction signature
'truncate': (v, [args]) {
final limit = (args?.firstOrNull as int?) ?? 80;
final s = v.toString();
return s.length <= limit ? s : '${s.substring(0, limit)}…';
},
})
i18n Message Expressions #
Use #{key} to look up messages from a MessageSource:
<p tl:text="#{welcome.title}">Welcome</p>
<p tl:text="#{greeting(${user.name})}">Hello, user!</p>
Wire up a MessageSource when constructing the engine:
Trellis(
messageSource: MapMessageSource(messages: {
'en': {
'welcome.title': 'Welcome to Trellis',
'greeting': 'Hello, {0}!',
},
'es': {
'welcome.title': 'Bienvenido a Trellis',
'greeting': '¡Hola, {0}!',
},
}),
locale: 'en', // default locale; override per-request via _locale context key
)
Positional placeholders {0}, {1}, ... are replaced by the arguments passed in the expression. Missing keys return the key itself in lenient mode or throw in strict mode.
Expression Syntax #
| Expression | Example | Description |
|---|---|---|
| Variable | ${user.name} |
Dot-notation, null-safe traversal |
| Selection | *{field} |
Field access on tl:object context |
| Message | #{welcome.title} |
i18n key lookup via MessageSource |
| URL | @{/users(id=${userId})} |
URL with query params |
| String literal | 'hello' |
Single-quoted string |
| Literal substitution | |Hello, ${name}!| |
Pipe-delimited template string |
| Arithmetic | ${a} + ${b}, - * / % |
Numeric arithmetic |
| Dynamic index | ${list[index]} |
List/map access with expression index |
| Ternary | ${active} ? 'yes' : 'no' |
Conditional expression |
| Elvis | ${val} ?: 'default' |
Null-coalescing |
| Comparison | ${a} == ${b}, !=, <, >, <=, >= |
Value comparison |
| Comparison alias | gt, lt, ge, le, eq, ne |
Word-form comparison operators |
| Boolean | ${a} and ${b}, or, not / ! |
Logical operators |
| Concat | ${first} + ' ' + ${last} |
String concatenation |
| Filter | ${name | upper}, ${price | fmt:'USD'} |
Pipe-based value transformation, with optional args |
| No-op | _ |
Explicitly do nothing (prototype preservation) |
HTMX Fragment Example #
import 'package:trellis/trellis.dart';
// Also add shelf: dart pub add shelf shelf_io
final engine = Trellis(loader: FileSystemLoader('templates/'));
// Full page render
final page = engine.render(pageTemplate, {'items': items});
// HTMX partial -- render only the fragment
final fragment = engine.renderFragment(
pageTemplate,
fragment: 'itemList',
context: {'items': items},
);
// Render multiple fragments in one call
final fragments = engine.renderFragments(
pageTemplate,
fragments: ['header', 'itemList'],
context: {'items': items},
);
Configuration #
Trellis(
loader: FileSystemLoader('templates/'), // Default
cache: true, // DOM caching with deep-clone (default: true)
maxCacheSize: 100, // LRU eviction threshold (default: 256)
prefix: 'tl', // Attribute prefix (default: 'tl')
strict: false, // Throw on undefined variables/members (default: false)
messageSource: ..., // i18n MessageSource implementation
locale: 'en', // Default locale for message lookup
processors: [...], // Additional custom Processor instances
dialects: [...], // Dialect instances contributing processors + filters
includeStandard: true, // Include the built-in StandardDialect (default: true)
)
TrellisContext is a fluent builder for constructing rendering context maps:
final context = TrellisContext()
.set('title', 'Hello')
.set('user', {'name': 'Alice'})
.setAll({'items': ['a', 'b', 'c']})
.build();
final html = engine.render(template, context);
Template Loaders #
FileSystemLoader(basePath)-- loads from filesystem with security boundariesAssetLoader(packageUri)-- loads from Dart package assets (JIT only, see AOT limitations)CompositeLoader(delegates)-- tries multiple loaders in order with fallbackMapLoader(templates)-- in-memory templates, useful for testing
Custom Processors #
Implement the Processor interface to add new tl:* attributes. Processors declare their attribute name, priority, and whether to recurse into children:
class HighlightProcessor implements Processor {
@override
String get attribute => 'highlight';
@override
ProcessorPriority get priority => ProcessorPriority.afterContent;
@override
bool get autoProcessChildren => true;
@override
bool process(Element element, String value, ProcessorContext context) {
element.attributes['style'] =
'${element.attributes['style'] ?? ''}background:yellow';
element.attributes.remove('tl:highlight');
return true;
}
}
final engine = Trellis(processors: [HighlightProcessor()]);
Dialects #
Group processors and filters into a reusable Dialect:
class MyDialect implements Dialect {
@override
String get name => 'MyDialect';
@override
List<Processor> get processors => [HighlightProcessor()];
@override
Map<String, Function> get filters => {
'shout': (v) => v.toString().toUpperCase() + '!!!',
};
}
final engine = Trellis(dialects: [MyDialect()]);
// Use only custom dialects, omitting the built-in StandardDialect:
final minimal = Trellis(dialects: [MyDialect()], includeStandard: false);
Framework Integration #
Trellis integrates with any Dart server framework in a few lines. Here's a minimal shelf example:
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:trellis/trellis.dart';
final engine = Trellis(loader: FileSystemLoader('templates/'));
Future<Response> handler(Request request) async {
final html = await engine.renderFile('index', {'title': 'Hello'});
return Response.ok(html,
headers: {'content-type': 'text/html; charset=utf-8'},
);
}
void main() async {
await shelf_io.serve(handler, 'localhost', 8080);
}
HTMX partial responses use renderFragment():
// Return only the todo list fragment for HTMX swap
final html = engine.renderFragment(
source,
fragment: 'todoList',
context: {'todos': todos},
);
Full guide with shelf middleware, dart_frog handlers, HTMX OOB swaps, and error handling: Framework Integration Guide.
HTML5-Valid Attribute Names #
Use data-tl-* attributes to pass HTML5 validation:
Trellis(prefix: 'data-tl')
<p data-tl-text="${message}">placeholder</p>
Public API #
| Method / Property | Returns | Description |
|---|---|---|
render(source, context) |
String |
Render template string |
renderFile(name, context) |
Future<String> |
Load and render template file |
renderFragment(source, fragment:, context:) |
String |
Render named fragment from string |
renderFileFragment(name, fragment:, context:) |
Future<String> |
Load file and render named fragment |
renderFragments(source, fragments:, context:) |
String |
Render multiple fragments concatenated |
renderFileFragments(name, fragments:, context:) |
Future<String> |
Load file and render multiple fragments |
clearCache() |
void |
Clear DOM cache and reset statistics |
cacheStats |
CacheStats |
Hit/miss/size metrics for the DOM cache |
ExpressionEvaluator can be used standalone for expression evaluation without templates:
final evaluator = ExpressionEvaluator(strict: true);
final result = evaluator.evaluate(r'${a} + ${b}', {'a': 1, 'b': 2}); // 3
Error Handling #
| Exception | When |
|---|---|
TemplateException |
Base class for all template errors |
ExpressionException |
Malformed or unevaluable expression (also thrown in strict mode for undefined variables) |
FragmentNotFoundException |
Named fragment not found in template |
TemplateNotFoundException |
Template file not found by loader |
TemplateSecurityException |
Path traversal or symlink escape attempt |
Security #
tl:utextrenders unescaped HTML -- only use with trusted content to avoid XSStl:inlineinjavascript/cssmode escapes output including</script>and</style>closing tagsFileSystemLoaderrejects absolute paths,..traversal, and symlink escapes outside the base directorytl:textalways HTML-escapes output
Contributing #
Trellis is in early development and we're not accepting pull requests at this time. That said, we'd love to hear from you — bug reports, feature ideas, and general feedback are all very welcome! Please feel free to open an issue.
License #
MIT