Skip to content

tolo/trellis

Repository files navigation

Trellis

pub package package publisher

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
  • Template inheritance -- tl:extends + tl:define for layout-based composition with named block overrides
  • Fragment-first -- tl:fragment + renderFragment() maps directly to HTMX partial responses
  • Full expression language -- variables, arithmetic, literal substitution, selection, URL, ternary, Elvis, comparisons, boolean
  • Expression utility objects -- ${#strings.*}, ${#numbers.*}, ${#dates.*}, ${#lists.*} for common string, number, date, and list operations (53 methods)
  • i18n message expressions -- #{key} with MessageSource, parameterized messages, and locale support
  • Filter arguments -- | filterName:arg1:arg2 parameterized 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}" and tl:insert="~{file :: .class}"
  • Custom processors & dialects -- register Processor implementations; compose feature sets with Dialect
  • Template validation -- TemplateValidator, isValidTemplate(), and dart run trellis:validate for test/CI checks
  • Sync-first API -- render() is synchronous; renderFile() is async only for I/O
  • LRU DOM cache -- configurable size, CacheStats for DOM and expression cache metrics
  • Strict mode -- undefined variables/members throw ExpressionException
  • Secure -- FileSystemLoader enforces 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
Utility object ${#strings.capitalize(name)}, ${#lists.size(items)} Built-in utility methods for strings, numbers, dates, and lists
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)
  devMode: false,        // File watching for dev (default: false)
  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);

Dev Mode (File Watching)

Enable devMode to automatically reload templates when files change on disk — ideal for development:

final engine = Trellis(
  loader: FileSystemLoader('templates/', devMode: true),
  devMode: true,
);

// Templates are re-read automatically when modified.
// Call close() when shutting down to release the file watcher:
await engine.close();

When devMode is false (the default), no file watcher is created and there is zero runtime overhead.

Note: Trellis.close() also closes the associated FileSystemLoader. If you share a loader across multiple engine instances, manage the loader's lifecycle separately.

Template Loaders

  • FileSystemLoader(basePath) -- loads from filesystem with security boundaries
  • AssetLoader(packageUri) -- loads from Dart package assets (JIT only, see AOT limitations)
  • CompositeLoader(delegates) -- tries multiple loaders in order with fallback
  • MapLoader(templates) -- in-memory templates, useful for testing

Template Validation

Validate templates in unit tests with the matcher from package:trellis/testing.dart:

import 'package:test/test.dart';
import 'package:trellis/testing.dart';

void main() {
  test('template is valid', () {
    expect('<p tl:text="${name}">x</p>', isValidTemplate());
  });
}

For direct validation APIs, use TemplateValidator:

final validator = TemplateValidator();
final issues = validator.validate('<p tl:text=""></p>');

For CI or local checks, validate a directory recursively:

dart run trellis:validate
dart run trellis:validate --dir templates --prefix tl

CLI behavior:

  • Success prints nothing and exits 0
  • Validation issues are printed to stderr as path:line: severity: message (attribute)
  • Validation errors exit 1
  • Warnings are printed but do not change the exit code unless at least one error is present

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, Relic handlers, HTMX OOB swaps, template testing, and error handling: Framework Integration Guide.

Framework Integration Packages

Package Description
trellis_shelf Shelf middleware, response helpers, security headers, CSRF protection
trellis_dart_frog Dart Frog integration — trellisProvider() middleware, response helpers, HTMX helpers, CSRF
trellis_relic Serverpod Relic integration — response helpers, HTMX helpers, security headers
trellis_dev Hot reload via SSE — browser auto-refresh on template file changes
trellis_css SASS/SCSS compilation and tl:scope CSS scoping for fragments
trellis_site Static site generator — Markdown, taxonomies, pagination, RSS/Atom feeds, JSON search index
trellis_cli trellis create scaffolding CLI — Shelf, Dart Frog, and blog starter templates

Template Testing

Testing utilities are built into the core package via testing.dart:

import 'package:trellis/testing.dart';
import 'package:test/test.dart';

void main() {
  final engine = testEngine(templates: {
    'card': '<div tl:fragment="card"><h2 tl:text="${title}">Title</h2></div>',
  });

  test('card fragment renders title', () {
    final html = testFragment(engine, 'card', 'card', {'title': 'Hello'});
    expect(html, hasElement('h2', withText: 'Hello'));
    expect(html, hasNoElement('.missing'));
  });

  test('snapshot', () async {
    await expectSnapshotFromSource(engine, '<p tl:text="${msg}">x</p>', {'msg': 'hi'});
  });
}

No extra dependency needed — just import 'package:trellis/testing.dart' in your tests.

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
warmUp(names) Future<WarmUpResult> Pre-load named templates into the DOM cache
warmUpAll() Future<WarmUpResult> Discover and pre-load templates from FileSystemLoader/MapLoader
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 DOM cache plus expressionCacheSize
close() Future<void> Release file-watch resources; also closes the loader (no-op when devMode is false)

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:utext renders unescaped HTML -- only use with trusted content to avoid XSS
  • tl:inline in javascript/css mode escapes output including </script> and </style> closing tags
  • FileSystemLoader rejects absolute paths, .. traversal, and symlink escapes outside the base directory
  • tl:text always HTML-escapes output

Developer Setup

The repository uses a Dart workspace with Melos for multi-package scripts.

One-time setup: activate Melos globally so melos run <script> works:

dart pub global activate melos
dart pub get          # resolves all workspace packages

Common commands (with global Melos):

melos run analyze     # static analysis across all packages
melos run test        # run tests in all packages (ordered by dependency)
melos run format:check  # check formatting

CI-safe alternatives (no global Melos required):

dart run melos exec --dir-exists lib -- dart analyze --fatal-infos
dart run melos exec --dir-exists test --fail-fast --order-dependents -- dart test

Pre-publication Validation

Before publishing packages, validate the generated starter app works with the local packages:

# 1. Generate a test project
dart run packages/trellis_cli/bin/trellis.dart create smoke_test

# 2. Add dependency_overrides so it uses local packages (not pub.dev)
cat >> smoke_test/pubspec.yaml << 'EOF'
dependency_overrides:
  trellis:
    path: ../packages/trellis
  trellis_shelf:
    path: ../packages/trellis_shelf
  trellis_dev:
    path: ../packages/trellis_dev
EOF

# 3. Resolve, analyze, and run
cd smoke_test
dart pub get
dart analyze --fatal-infos
dart run bin/server.dart   # visit http://localhost:8080

Publishing order: publish packages in dependency order — trellis first, then trellis_shelf, trellis_dev, and trellis_css (all depend on trellis), then trellis_dart_frog and trellis_relic (depend on trellis_shelf), then trellis_site and trellis_cli last.

Post-publication user path:

dart pub global activate trellis_cli
trellis create my_app
cd my_app && dart pub get && dart run bin/server.dart

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages