Extending Mau
View SourceLearn how to extend Mau's architecture and add custom functionality.
Overview
Mau is designed to be extensible. This guide covers the architecture and how to extend the template engine with custom features.
Architecture Overview
Mau follows a pipeline architecture:
Template String
↓
[Parser]
↓
AST Nodes
↓
[Whitespace Processor]
↓
[Block Processor]
↓
Renderer
↓
Output (String or typed)Core Modules
Mau.Parser
- Converts template string to AST
- Handles all template syntax
- Returns AST nodes
Mau.WhitespaceProcessor
- Processes whitespace control modifiers (-, ->, --)
- Trims whitespace around expressions and tags
Mau.BlockProcessor
- Processes block-level structures
- Handles if/elsif/else and for loops
- Nests expressions within control structures
Mau.Renderer
- Evaluates expressions
- Renders tags
- Applies filters
- Manages context and variables
Mau.MapDirectives
- Handles special directives for map transformation
- Supports #map, #filter, #merge, #if, #pick, #pipe
Custom Filters (Easiest Extension)
The simplest way to extend Mau - create domain-specific filters:
defmodule MyApp.Filters do
def spec do
%{
category: :app,
description: "Application-specific filters",
filters: %{
"format_currency" => %{
description: "Format as currency",
function: {__MODULE__, :format_currency}
}
}
}
end
def format_currency(amount, _args) when is_number(amount) do
{:ok, "$#{Float.round(amount, 2)}"}
end
def format_currency(_value, _args) do
{:error, "format_currency requires a number"}
end
end
# Register in config/config.exs
config :mau, filters: [MyApp.Filters]See Custom Filters Guide for detailed examples.
Custom Directives
Extend map rendering with custom directives:
defmodule MyApp.CustomDirectives do
def match_directive(%{"#custom_transform" => args}) when is_list(args) do
if length(args) == 2 do
{:custom_transform, args}
else
:none
end
end
def match_directive(_), do: :none
def apply_directive({:custom_transform, [source_template, transform_args]}, context, opts, render_fn) do
# 1. Render the source
source = render_fn.(source_template, context, opts)
# 2. Apply transformation
transform_directive = %{
"transformed" => transform_args
}
# 3. Return transformed result
render_fn.(transform_directive, context ++ [source: source], opts)
end
endIntegrate into Mau.MapDirectives.match_directive/1:
def match_directive(map) do
case map do
%{"#map" => args} when is_list(args) and length(args) == 2 -> {:map, args}
%{"#filter" => args} when is_list(args) and length(args) == 2 -> {:filter, args}
# ... existing directives ...
_ -> MyApp.CustomDirectives.match_directive(map)
end
endCustom Tag Types
Add new control flow tags:
defmodule MyApp.CustomTags do
# Pattern for custom tags
def render_tag(:custom_repeat, [times, content], _opts, context) do
times_val = case times do
{:literal, [n], _} when is_integer(n) -> n
_ -> 1
end
# Render content N times
output = Enum.map(1..times_val, fn _i ->
case Mau.Renderer.render(content, context) do
{:ok, result} -> result
{:error, _} -> ""
end
end)
{:ok, Enum.join(output)}
end
def render_tag(tag_name, _params, _opts, _context) do
{:error, "Unknown tag: #{tag_name}"}
end
endMiddleware/Hooks
Add processing hooks at different stages:
defmodule MyApp.TemplateHooks do
# Pre-compilation hook
def pre_compile(template_string) do
# Apply transformations before compilation
template_string
|> replace_custom_syntax()
|> normalize_whitespace()
end
# Post-compilation hook
def post_compile(ast) do
# Validate or optimize AST
validate_ast(ast)
optimize_ast(ast)
end
# Pre-render hook
def pre_render(context) do
# Enrich context
context
|> add_globals()
|> sanitize_values()
end
# Post-render hook
def post_render(output) do
# Process output
output
|> String.trim()
|> remove_debug_markers()
end
defp replace_custom_syntax(template) do
# Replace custom syntax with standard syntax
template
end
defp normalize_whitespace(template) do
# Normalize whitespace
template
end
defp validate_ast(ast) do
# Validate AST structure
ast
end
defp optimize_ast(ast) do
# Optimize AST for faster rendering
ast
end
defp add_globals(context) do
Map.merge(context, %{
"now" => DateTime.utc_now(),
"version" => Application.spec(:my_app)[:vsn]
})
end
defp sanitize_values(context) do
# Clean values
context
end
defp remove_debug_markers(output) do
# Remove markers
output
end
endUsage:
defmodule MyApp.SecureRenderer do
def render(template, context) do
template = MyApp.TemplateHooks.pre_compile(template)
{:ok, ast} = Mau.compile(template)
ast = MyApp.TemplateHooks.post_compile(ast)
context = MyApp.TemplateHooks.pre_render(context)
{:ok, output} = Mau.render(ast, context)
output = MyApp.TemplateHooks.post_render(output)
{:ok, output}
end
endCustom Expression Evaluators
Create custom expression types:
defmodule MyApp.CustomExpressions do
# Extend expression evaluation
def evaluate_expression({:custom_expr, [type | args], _opts}, context) do
case type do
:range -> evaluate_range(args, context)
:set -> evaluate_set(args, context)
:regex -> evaluate_regex(args, context)
_ -> {:error, "Unknown custom expression type: #{type}"}
end
end
defp evaluate_range([start, finish], context) do
{:ok, start..finish}
end
defp evaluate_set(args, context) do
{:ok, MapSet.new(args)}
end
defp evaluate_regex([pattern], _context) do
{:ok, Regex.compile!(pattern)}
end
endCustom Renderer
Create a specialized renderer for specific use cases:
defmodule MyApp.XMLRenderer do
@moduledoc """
Render templates as XML with special handling.
"""
def render(template, context) do
case Mau.compile(template) do
{:ok, ast} ->
render_xml_nodes(ast, context)
error ->
error
end
end
defp render_xml_nodes(nodes, context) when is_list(nodes) do
case Enum.reduce_while(nodes, [], fn node, acc ->
case render_xml_node(node, context) do
{:ok, result} -> {:cont, [result | acc]}
{:error, reason} -> {:halt, {:error, reason}}
end
end) do
{:error, _} = error -> error
results -> {:ok, Enum.reverse(results) |> Enum.join()}
end
end
defp render_xml_node({:text, [content], _opts}, _context) do
{:ok, String.trim(content)}
end
defp render_xml_node({:expression, expr, _opts}, context) do
case Mau.Renderer.evaluate_expression(expr, context) do
{:ok, value} ->
# XML escape output
{:ok, xml_escape(to_string(value))}
error ->
error
end
end
defp xml_escape(text) do
text
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
|> String.replace("'", "'")
end
endParser Extensions
Extend the parser for custom syntax:
defmodule MyApp.ExtendedParser do
@moduledoc """
Parser with custom syntax support.
"""
def parse(template) do
# Transform template before standard parsing
transformed = transform_custom_syntax(template)
Mau.Parser.parse(transformed)
end
defp transform_custom_syntax(template) do
template
# Convert custom @include syntax to standard tags
|> String.replace(~r/@include\s+"([^"]+)"/, "{# include: \\1 #}")
# Convert custom #tag syntax
|> String.replace(~r/#each\s+(\w+)/, "{% for item in \\1 %}")
end
endFilter Registry Extensions
Add runtime filter loading:
defmodule MyApp.DynamicFilters do
def load_filters_from_directory(path) do
path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".ex"))
|> Enum.map(&load_filter_module(&1, path))
end
defp load_filter_module(filename, path) do
full_path = Path.join(path, filename)
content = File.read!(full_path)
case Code.compile_string(content) do
{module, _bytecode} -> {:ok, module}
:error -> {:error, "Failed to compile #{filename}"}
end
end
def register_filters(modules) do
existing = Application.get_env(:mau, :filters, [])
new_filters = existing ++ modules
Application.put_env(:mau, :filters, new_filters)
end
endContext Providers
Create pluggable context providers:
defmodule MyApp.ContextProvider do
@type context_provider :: (any() -> map())
@providers [
&user_context/1,
&config_context/1,
&locale_context/1
]
def build_context(request) do
@providers
|> Enum.reduce(%{}, fn provider, acc ->
Map.merge(acc, provider.(request))
end)
end
defp user_context(request) do
if user = request.user do
%{
"user" => %{
"id" => user.id,
"name" => user.name,
"role" => user.role
}
}
else
%{}
end
end
defp config_context(_request) do
%{
"config" => %{
"app_name" => Application.get_env(:my_app, :app_name),
"version" => Application.spec(:my_app)[:vsn]
}
}
end
defp locale_context(request) do
%{
"locale" => request.locale || "en",
"timezone" => request.timezone || "UTC"
}
end
end
# Usage
context = MyApp.ContextProvider.build_context(request)
{:ok, output} = Mau.render(template, context)Testing Extensions
Test custom extensions:
defmodule MyApp.ExtensionsTest do
use ExUnit.Case
test "custom filter works" do
assert {:ok, "$1,234.56"} =
Mau.FilterRegistry.apply("format_currency", 1234.56, [])
end
test "custom directive transforms data" do
input = %{
"result" => %{
"#custom_transform" => ["{{$value}}", [1, 2, 3]]
}
}
context = %{"$value" => 42}
assert {:ok, result} = Mau.render_map(input, context)
assert result["result"]
end
test "custom renderer produces XML" do
template = "<item>{{ value }}</item>"
context = %{"value" => "test"}
assert {:ok, output} = MyApp.XMLRenderer.render(template, context)
assert String.contains?(output, "<item>")
end
test "context provider merges sources" do
request = %{
user: %{id: 1, name: "Alice", role: "admin"},
locale: "fr",
timezone: "Europe/Paris"
}
context = MyApp.ContextProvider.build_context(request)
assert context["user"]["name"] == "Alice"
assert context["locale"] == "fr"
assert context["config"]["app_name"]
end
endBest Practices for Extensions
1. Follow Mau Conventions
- Use the same error handling pattern:
{:ok, result}or{:error, reason} - Document filters with spec function
- Use consistent naming
2. Maintain Compatibility
- Don't break existing APIs
- Version your extensions
- Provide migration paths
3. Performance
- Cache where possible
- Avoid expensive operations in hot paths
- Profile your extensions
4. Testing
- Write comprehensive tests
- Test edge cases
- Test error handling
5. Documentation
- Document custom syntax
- Provide examples
- Include troubleshooting
Common Extension Patterns
Logging/Tracing
defmodule MyApp.TracingRenderer do
def render(template, context, trace_fn) do
trace_fn.({:render_start, %{template_length: byte_size(template)}})
case Mau.render(template, context) do
{:ok, output} ->
trace_fn.({:render_complete, %{output_length: byte_size(output)}})
{:ok, output}
{:error, error} ->
trace_fn.({:render_error, %{error: error}})
{:error, error}
end
end
endMetrics Collection
defmodule MyApp.MetricsRenderer do
def render(template, context) do
{time_us, result} = :timer.tc(Mau, :render, [template, context])
:telemetry.execute([:mau, :render], %{duration_us: time_us})
result
end
endSee Also
- Custom Filters - Creating custom filters
- Custom Functions - Creating custom functions
- Performance Tuning - Optimization
- Security - Security considerations
- API Reference - API documentation