Extending Mau

View Source

Learn 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
end

Integrate 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
end

Custom 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
end

Middleware/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
end

Usage:

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
end

Custom 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
end

Custom 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("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&apos;")
  end
end

Parser 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
end

Filter 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
end

Context 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
end

Best 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
end

Metrics 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
end

See Also