Skip to main content

Extension Guide

How to extend mylekha_template_engine with custom filters, tags, modules, and template loaders.

Custom Filter

A filter is a plain Dart function matching the Filter typedef:

typedef Filter = dynamic Function(dynamic input, List<dynamic> args);

Register it directly on the context:

final context = Context.create();

// Simple filter — no args
context.filters['slug'] = (input, args) {
return input.toString().toLowerCase().replaceAll(RegExp(r'\s+'), '-');
};

// Filter with args
context.filters['truncate'] = (input, args) {
final limit = int.tryParse('${args.firstOrNull}') ?? 50;
final str = input?.toString() ?? '';
return str.length > limit ? '${str.substring(0, limit)}...' : str;
};

Use it in templates immediately:

{{ product.name | slug }}
{{ article.body | truncate: 100 }}
Tips
  • Always guard against null input: input?.toString() ?? ''
  • Parse numeric args with int.tryParse('${args[0]}') — args are always dynamic, typically strings
  • Filters can return any type (String, num, bool, List, Map) — the engine calls toString() only when emitting output

Understanding async* and yield* in render()

Read this before writing any custom block tag.

Every render() method is an async* generator that returns Stream<String>. This streaming architecture makes large template output memory-efficient.

// How Block.render works conceptually:
Stream<String> render(RenderContext context) async* {
// yield emits one string chunk into the stream
yield '<div>';

// yield* delegates to another stream — all its chunks flow through
yield* child.render(context);

yield '</div>';
}

Rules for writing a correct render():

PatternWhat it does
yield 'string'Emits one string chunk immediately
yield* streamDelegates — all chunks from the stream pass through in order
yield* someBlock.render(context)Renders a child block inline
await futureSuspends until the future resolves (usable inside async*)
final result = await someStream.join()Collects a stream into a single string
Common mistake — forgetting yield*
// ❌ WRONG: render() is called but its output is discarded
child.render(context);

// ✅ CORRECT: output flows through
yield* child.render(context);

Collecting a child's output into a string (for filters, capture, etc.):

final captured = await child.render(context).join();
context.variables['myVar'] = captured;
// emit nothing — capturing has no output

Custom Simple Tag (self-closing)

A self-closing tag has no inner content and no {% endtagname %}.

import 'package:mylekha_html_renderer/mylekha_html_renderer.dart';
import 'package:mylekha_html_renderer/src/block.dart';
import 'package:mylekha_html_renderer/src/context.dart';
import 'package:mylekha_html_renderer/src/model.dart';
import 'package:mylekha_html_renderer/src/tag.dart';

class HrTag extends Block {
final String cssClass;
HrTag(this.cssClass) : super([]);

@override
Stream<String> render(RenderContext context) async* {
yield '<hr class="$cssClass" />';
}

// Factory: receives the tag's token list, returns the Block
static Block factory(List<Token> tokens, List<Tag> children) {
final parser = TagParser.from(tokens);
final cssClass = parser.tryGetString() ?? 'divider';
return HrTag(cssClass);
}
}

// Register (self-closing = hasEndTag: false)
context.tags['hr'] = BlockParser.simple(HrTag.factory, hasEndTag: false);

Template usage:

{% hr "my-divider" %}

Custom Block Tag (with end tag)

A block tag wraps inner content between {% tagname %} and {% endtagname %}.

class HighlightBlock extends Block {
final String color;
HighlightBlock(this.color, List<Tag> children) : super(children);

@override
Stream<String> render(RenderContext context) async* {
yield '<span style="background:$color">';
yield* super.render(context); // render all children
yield '</span>';
}

static Block factory(List<Token> tokens, List<Tag> children) {
final parser = TagParser.from(tokens);
final color = parser.tryGetString() ?? 'yellow';
return HighlightBlock(color, children);
}
}

// Register (default hasEndTag: true)
context.tags['highlight'] = BlockParser.simple(HighlightBlock.factory);

Template usage:

{% highlight "cyan" %}
{{ product.name }}
{% endhighlight %}

Custom Complex Tag (with inner sub-tags)

Some tags need to handle inner structural tags (like {% else %} inside {% if %}). Override BlockParser directly.

class RepeatBlock extends Block {
final int times;
final List<Tag> body;
final List<Tag> separator;

RepeatBlock(this.times, this.body, this.separator) : super([]);

@override
Stream<String> render(RenderContext context) async* {
for (var i = 0; i < times; i++) {
yield* renderTags(context, body);
if (i < times - 1 && separator.isNotEmpty) {
yield* renderTags(context, separator);
}
}
}
}

class _RepeatParser extends BlockParser {
List<Tag> _body = [];

@override
Block create(List<Token> tokens, List<Tag> children) {
final parser = TagParser.from(tokens);
final times = int.tryParse('${parser.current.value}') ?? 1;
return RepeatBlock(times, _body, children);
}

@override
void unexpectedTag(
Parser parser,
Token start,
List<Token> args,
List<Tag> childrenSoFar,
) {
if (start.value == 'separator') {
// Everything collected so far is the body
_body = List.from(childrenSoFar);
childrenSoFar.clear();
} else {
super.unexpectedTag(parser, start, args, childrenSoFar);
}
}
}

// Register
context.tags['repeat'] = () => _RepeatParser();

Template usage:

{% repeat 3 %}
<li>{{ item.name }}</li>
{% separator %}
<hr />
{% endrepeat %}

Custom Module

A module bundles related filters and tags. Activate it per-template with {% load %}.

class PrintModule implements Module {
@override
void register(Context context) {
context.filters['bold'] = (input, args) => '<strong>$input</strong>';
context.filters['italic'] = (input, args) => '<em>$input</em>';
context.tags['page_break'] = BlockParser.simple(
(tokens, children) => _PageBreakBlock(),
hasEndTag: false,
);
}
}

class _PageBreakBlock extends Block {
_PageBreakBlock() : super([]);

@override
Stream<String> render(RenderContext context) async* {
yield '<div style="page-break-after:always"></div>';
}
}

// Register the module before parsing
context.modules['print'] = PrintModule();

Template usage (must appear at the top of the template):

{% load print %}

{{ product.name | bold }}
{% page_break %}

Module Lifecycle

flowchart LR
A["context.modules\n'print' = PrintModule()"] -->|Template.parse| B["{% load print %}\nbecomes Load block in AST"]
B -->|template.render| C["Load.render called\ncontext.registerModule 'print'"]
C --> D["modules['print']\n.register(context)"]
D --> E["context.filters\n'bold', 'italic' added"]
D --> F["context.tags\n'page_break' added"]
E --> G["Available to all tags\nrendered after this point"]
F --> G
note

{% load %} blocks are rendered before any other content in the document. Filters and tags registered by a module are available to all tags in that template and any {% include %}d partials that share the same context.


Custom Template Loader (Root)

Implement Root to control where templates are loaded from — database, network, asset bundle, in-memory map, etc.

abstract class Root {
Future<Source> resolve(String relPath);
}

File system (built-in)

final root = BuildPath(Uri.directory('/path/to/templates'));
final source = await root.resolve('receipt.html');

In-memory map

class MapRoot implements Root {
final Map<String, String> templates;
MapRoot(this.templates);

@override
Future<Source> resolve(String relPath) async {
final content = templates[relPath];
if (content == null) throw Exception('Template not found: $relPath');
return Source(Uri.parse(relPath), content, this);
}
}

final root = MapRoot({
'base.html': '<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>',
'page.html': '{% extends "base.html" %}{% block content %}Hello!{% endblock %}',
});

Flutter asset bundle

import 'package:flutter/services.dart';

class AssetRoot implements Root {
final String basePath;
AssetRoot(this.basePath);

@override
Future<Source> resolve(String relPath) async {
final content = await rootBundle.loadString('$basePath/$relPath');
return Source(Uri.parse(relPath), content, this);
}
}

Database / API

class ApiRoot implements Root {
final http.Client client;
final String baseUrl;
ApiRoot(this.client, this.baseUrl);

@override
Future<Source> resolve(String relPath) async {
final response = await client.get(Uri.parse('$baseUrl/$relPath'));
if (response.statusCode != 200) throw Exception('Template $relPath not found');
return Source(Uri.parse(relPath), response.body, this);
}
}
important

Pass this as the third argument to Source(uri, content, this). This ensures nested {% include %} and {% extends %} calls use the same root to resolve their own relative paths.


Testing Extensions

import 'package:test/test.dart';
import 'package:mylekha_html_renderer/mylekha_html_renderer.dart';

void main() {
group('custom filters', () {
late Context context;

setUp(() {
context = Context.create();
context.filters['slug'] = (input, args) =>
input.toString().toLowerCase().replaceAll(RegExp(r'\s+'), '-');
});

test('slug filter converts spaces to hyphens', () async {
final t = Template.parse(
context,
Source(null, '{{ name | slug }}', null),
);
context.variables['name'] = 'Hello World';
expect(await t.render(context), 'hello-world');
});
});
}