lib/liquex/tag/render_tag.ex

defmodule Liquex.Tag.RenderTag do
  @moduledoc """
  Insert the rendered content of another template within the current template.

      {% render "template-name" %}

  Note that you don’t need to write the file’s .liquid extension.

  The code within the rendered template does not automatically have access to
  the variables assigned using variable tags within the parent template.
  Similarly, variables assigned within the rendered template cannot be accessed
  by code in any other template.

  ## render (parameters)

  Variables assigned using variable tags can be passed to a template by listing
  them as parameters on the render tag.

      {% assign my_variable = "apples" %}
      {% render "name", my_variable: my_variable, my_other_variable: "oranges" %}

  One or more objects can be passed to a template.

      {% assign featured_product = all_products["product_handle"] %}
      {% render "product", product: featured_product %}

  ## with

  A single object can be passed to a template by using the with and optional as
  parameters.

      {% assign featured_product = all_products["product_handle"] %}
      {% render "product" with featured_product as product %}

  In the example above, the product variable in the rendered template will hold
  the value of featured_product from the parent template.

  ## for

  A template can be rendered once for each value of an enumerable object by
  using the for and optional as parameters.

      {% assign variants = product.variants %}
      {% render "product_variant" for variants as variant %}

  In the example above, the template will be rendered once for each variant of
  the product, and the variant variable will hold a different product variant
  object for each iteration.

  When using the for parameter, the forloop object is accessible within the
  rendered template.
  """

  @behaviour Liquex.Tag
  import NimbleParsec

  alias Liquex.Argument
  alias Liquex.FileSystem

  alias Liquex.Parser.Field
  alias Liquex.Parser.Literal
  alias Liquex.Parser.Object
  alias Liquex.Parser.Tag

  alias Liquex.Tag.ForTag

  alias Liquex.Context

  @spec parse :: NimbleParsec.t()
  def parse do
    Tag.open_tag()
    |> do_parse()
    |> ignore(Tag.close_tag())
  end

  def parse_liquid_tag do
    do_parse()
    |> ignore(Tag.end_liquid_line())
  end

  defp do_parse(combinator \\ empty()) do
    combinator
    |> string("render")
    |> Literal.whitespace(1)
    |> ignore()
    |> Literal.literal()
    |> unwrap_and_tag(:template)
    |> optional(choice([keyword_list(), with_clause(), for_loop()]))
  end

  defp keyword_list do
    string(",")
    |> Literal.whitespace()
    |> ignore()
    |> Object.keyword_fields()
    |> tag(:keywords)
  end

  defp with_clause do
    Literal.whitespace(empty(), 1)
    |> string("with")
    |> Literal.whitespace(1)
    |> ignore()
    |> Field.field()
    |> optional(alias())
    |> tag(:with)
  end

  defp for_loop do
    Literal.whitespace(empty(), 1)
    |> string("for")
    |> Literal.whitespace(1)
    |> ignore()
    |> unwrap_and_tag(Field.field(), :collection)
    |> optional(alias())
    |> tag(:for)
  end

  defp alias do
    empty()
    |> Literal.whitespace(1)
    |> string("as")
    |> Literal.whitespace(1)
    |> ignore()
    |> Field.identifier()
    |> unwrap_and_tag(:as)
  end

  def render(
        [template: template],
        %Context{} = context
      ) do
    render([template: template, keywords: []], context)
  end

  def render(
        [template: template, keywords: keywords],
        %Context{} = context
      ) do
    new_context = apply_keywords_to_context(context, keywords)

    template
    |> load_contents(context)
    |> Liquex.Render.render!(new_context)
    |> case do
      {content, _new_context} -> {content, context}
      # break/continue do not get propagated to parent context
      {_operation, content, _new_context} -> {content, context}
    end
  end

  # Handle with field as alias
  #
  # {% render "template_name" with field as identifier %}
  def render([template: template, with: [field: field, as: identifier]], context) do
    keywords = [keyword: [identifier, [field: field]]]
    render([template: template, keywords: keywords], context)
  end

  # Handle with field without alias
  #
  # {% render "template_name" with field %}
  def render([template: {:literal, template_name}, with: field], context) do
    keywords = [keyword: [template_name, field]]
    render([template: {:literal, template_name}, keywords: keywords], context)
  end

  # Handle for loop without identifier alias
  #
  # {% render "template_name" for collection %}
  def render(
        [template: {:literal, template}, for: [collection: collection]],
        %Context{} = context
      ) do
    # Define the identifier as the template name
    render(
      [
        template: {:literal, template},
        for: [collection: collection, as: template]
      ],
      context
    )
  end

  # Handle for loop with identifier alias
  #
  # {% render "template_name" for collection as identifier %}
  def render(
        [template: template, for: [collection: collection, as: identifier]],
        %Context{} = context
      ) do
    contents = load_contents(template, context)

    new_context = apply_keywords_to_context(context, [])

    # Piggy back off `Liquex.Tag.ForTag` to fully support forloop variable
    {result, _context} =
      collection
      |> Liquex.Argument.eval(context)
      |> Liquex.Expression.eval_collection()
      |> Liquex.Collection.to_enumerable()
      |> ForTag.render_collection(identifier, contents, nil, new_context)

    {result, context}
  end

  @spec apply_keywords_to_context(Context.t(), Keyword.t()) :: Context.t()
  defp apply_keywords_to_context(%Context{} = context, keywords) do
    scope = Map.new(keywords, fn {:keyword, [k, v]} -> {k, Argument.eval(v, context)} end)

    Context.new_isolated_subscope(context, scope)
  end

  @spec load_contents({:literal, String.t()}, Context.t()) :: Liquex.document_t() | no_return()
  defp load_contents({:literal, template_name}, %Context{
         file_system: file_system,
         cache: cache,
         cache_prefix: cache_prefix
       }) do
    cache.fetch("#{cache_prefix}:Liquex.Tag.RenderTag:partial." <> template_name, fn ->
      file_system
      |> FileSystem.read_template_file(template_name)
      |> Liquex.parse!()
    end)
  end
end