Solid: Liquid Template Language for Elixir

repository·main·Indexed 42 days ago

https://github.com/edgurgel/solid

An Elixir implementation of the Liquid template language featuring strict parsing and type-safe handling. Solid supports custom tags via the Solid.Tag behaviour, custom filters, and template caching via the Solid.Caching behaviour. It includes a ~LIQUID sigil for compile-time validation, a LocalFileSystem for template loading, and the Solid.Matcher protocol for accessing Elixir structs using dot notation.

Tokens
8.1K
Snippets
29
Records
34
Agent score
54%

What's inside Solid

  1. Enable strict rendering for variables and filters

    main

    By default, Solid may fail silently or handle missing data gracefully. To enforce strictness, use the following options in Solid.render/3:

    • strict_variables: true: If a variable is missing, Solid.render/3 returns {:error, errors, result}. Solid.render!/3 will raise an error.
    • strict_filters: true: If a filter is missing, Solid.render/3 returns {:error, errors, result}. Solid.render!/3 will raise an error.

    In the non-bang version (render/3), errors is a list of all collected errors encountered during rendering.

  2. Use strict filter mode to catch undefined filters

    main

    By default, if a filter is not found in the custom filters or the standard filters, Solid may fail silently or return the original input depending on the context. You can enable strict_filters in the options to force an error when a filter is undefined.

    If strict_filters is set to true and a filter is not found, Solid raises a %Solid.UndefinedFilterError{loc: loc, filter: filter}.

  3. Access tablerow loop variables

    main

    Inside the body of a {% tablerow %} block, you can access a special tablerowloop object via the context to get metadata about the current cell's position within the table. This is useful for conditional styling or logic based on the cell's location.

    Available keys in tablerowloop:

    • row: The current row number (1-indexed).
    • col: The current column number (1-indexed).
    • index: The absolute 1-indexed position of the item in the collection.
    • index0: The 0-indexed position of the item.
    • col0: The 0-indexed column number.
    • col_first: Boolean, true if it is the first column in the row.
    • col_last: Boolean, true if it is the last column in the row.
    • first: Boolean, true if it is the very first cell in the entire table (row 1, col 1).
    • last: Boolean, true if it is the very last cell in the entire table.
    • length: The total number of items in the collection.
    • rindex: The reverse index (remaining items).
    • rindex0: The 0-indexed reverse index.
  4. Handle rendering errors in Solid

    main

    Solid provides specific error types to help debug template issues:

    • Solid.RenderError: Raised by render!/3 when errors occur. It contains the error messages, the list of specific errors, and the partial result produced before the error.
    • Solid.TemplateError: Returned by parse/2 when the template syntax is invalid. It contains a list of Solid.ParserError.t() structs.
    • Solid.ParserError: Contains the reason, meta (line and column), and the original text where the parsing error occurred.

    Note: If you are using render/3 (the non-bang version), errors are collected in the third element of the return tuple and do not raise exceptions unless strict_variables or strict_filters are enabled in the options.

  5. Implement a custom FileSystem for template includes

    main

    To allow templates to use the include tag, you must provide a module that implements the Solid.FileSystem behaviour. This allows you to retrieve template content from various sources like a database, a specific directory structure, or hard-coded strings.

    To use a custom file system, pass it to Solid.render/2 using the file_system option in the format {Module, instance}.

    # 1. Create your file system instance
    file_system = Solid.LocalFileSystem.new(template_path)
    
    # 2. Pass it to Solid.render
    text = Solid.render(template, file_system: {Solid.LocalFileSystem, file_system})
  6. Use the {% tablerow %} tag to generate HTML tables

    main

    The {% tablerow %} tag is used to iterate over a collection and generate an HTML table structure. It must be wrapped within opening <table> and closing </table> HTML tags.

    Each item in the collection is rendered inside a <tr> (table row) with a class of rowN (where N is the row number), and each item within that row is rendered inside a <td> (table cell) with a class of colN (where N is the column number).

    Syntax:

    <table>
      {% tablerow variable in collection %}
      {{ variable.property }}
      {% endtablerow %}
    </table>
    <table>
      {% tablerow product in collection.products %}
      {{ product.title }}
      {% endtablerow %}
    </table>
  7. Basic usage of Solid templates

    main

    To use Solid, first parse a template string using Solid.parse/1 (or Solid.parse!/1), then render it using Solid.render/2 (or Solid.render!/2) by providing a context map.

    # Parsing and rendering a simple template
    template = "My name is {{ user.name }}"
    {:ok, template} = Solid.parse(template)
    Solid.render!(template, %{ "user" => %{ "name" => "José" } }) |> to_string
    # "My name is José"
  8. Implement custom tags with the Tag behaviour

    main

    To create a new tag, implement the Solid.Tag behaviour. Your module must implement parse/3, which takes the tag name, location (loc), and current context. It must return a struct that implements the Solid.Renderable behaviour.

    To use the tag, pass it to Solid.parse/2 via the tags option. You can use Solid.Tag.default_tags() as a base to include or exclude specific tags.

    defmodule CurrentYear do
      @enforce_keys [:loc]
      defstruct [:loc]
    
      @behaviour Solid.Tag
    
      @impl true
      def parse("get_current_year", loc, context) do
        with {:ok, [{:end, _}], context} <- Solid.Lexer.tokenize_tag_end(context) do
          {:ok, %__MODULE__{loc: loc}, context}
        end
      end
    
      defimpl Solid.Renderable do
        def render(_tag, context, _options) do
          {[to_string(Date.utc_today().year)], context}
        end
      end
    end
    
    # Registering the tag
    tags = Map.put(Solid.Tag.default_tags(), "get_current_year", CurrentYear)
    Solid.parse!("{{ get_current_year }}", tags: tags)
  9. Use structs in template context via Solid.Matcher

    main

    To allow Liquid to access fields within Elixir structs using dot notation (e.g., {{ user.email }}), implement the Solid.Matcher protocol for your struct.

    The protocol requires a match(data, keys) function, where data is the struct and keys is a list of strings representing the path of keys accessed.

    Alternatively, you can provide a global matcher module via the matcher_module option in Solid.render!/3.

    defmodule UserProfile do
      defstruct [:full_name]
    
      defimpl Solid.Matcher do
        def match(user_profile, ["full_name"]), do: {:ok, user_profile.full_name}
      end
    end
    
    defmodule User do
      defstruct [:email]
    
      def load_profile(%User{} = _user) do
        # implementation omitted
        %UserProfile{full_name: "John Doe"}
      end
    
      defimpl Solid.Matcher do
        def match(user, ["email"]), do: {:ok, user.email}
        def match(user, ["profile" | keys]), do: user |> User.load_profile() |> Solid.Matcher.match(keys)
      end
    end
    
    template = ~s({{ user.email}}: {{ user.profile.full_name }})
    context = %{"user" => %User{email: "test@example.com"}}
    
    template |> Solid.parse!() |> Solid.render!(context) |> to_string()
    # => "test@example.com: John Doe"
  10. Implement custom filters

    main

    You can provide custom filters during the render phase using the custom_filters option. You have two ways to do this:

    1. Pass a module: The module provides functions that take a value and return the transformed value.
    2. Pass a function: A callback function that allows access to external state (like a user's locale). The callback must return either {:ok, value} or :error.

    Example of using a function for stateful filters:

    user_locale = "en"
    
    "{{ number | format_number }}"
    |> Solid.parse!()
    |> Solid.render!(%{ "number" => 41}, custom_filters: fn
      "format_number", [num] ->
        {:ok, Cldr.Number.to_string(num, locale: user_locale)}
    
      "format_number", [num, locale] ->
        {:ok, Cldr.Number.to_string(num, locale: locale)}
    
      _, _ ->
        :error
    end)
    |> IO.puts()
  11. Validate templates at compile time with ~LIQUID

    main

    Import Solid.Sigil to use the ~LIQUID sigil. This allows you to validate template syntax at compile time. If the template contains syntax errors, the compiler will raise a CompileError with line numbers and context.

    Experimental VSCode syntax highlighting is available via the Liquid Sigil extension.

    import Solid.Sigil
    
    # Validates syntax at compile time
    template = ~LIQUID"""
    Hello, {{ name }}!
    """
    
    # Use the compiled template
    Solid.render!(template, %{"name" => "World"})