lib/live_view_native/templates.ex

defmodule LiveViewNative.Templates do
  @moduledoc """
  Provides functionality for preprocessing LiveView Native
  templates.
  """

  def precompile(expr, platform_id, eex_opts) do
    with_stylesheet_wrapper = Keyword.get(eex_opts, :with_stylesheet_wrapper, true)

    case compile_class_tree(expr, platform_id, eex_opts) do
      {:ok, _class_tree} when with_stylesheet_wrapper ->
        with_stylesheet_wrapper(expr)

      _ ->
        expr
    end
  end

  def compile_class_tree(expr, platform_id, eex_opts) do
    caller = eex_opts[:caller]
    %Macro.Env{module: template_module} = caller

    with doc <- Floki.parse_document!(expr),
         class_names <- extract_all_class_names(doc),
         %{} = class_tree_context <- class_tree_context(platform_id, template_module, eex_opts),
         %{} = class_tree <- build_class_tree(class_tree_context, class_names, eex_opts) do
      if eex_opts[:persist_class_tree], do: persist_class_tree_map(%{default: class_tree}, caller)

      {:ok, class_tree}
    else
      _ ->
        {:ok, :skipped}
    end
  end

  def persist_class_tree_map(class_tree_map, caller) do
    dump_class_tree_bytecode(class_tree_map, caller)
  end

  def with_stylesheet_wrapper(expr, stylesheet_key \\ :default) do
    "<compiled-lvn-stylesheet body={__compiled_stylesheet__(:#{stylesheet_key})}>#{expr}</compiled-lvn-stylesheet>"
  end

  ###

  defp build_class_tree(%{} = class_tree_context, class_names, eex_opts) do
    {func_name, func_arity} = eex_opts[:render_function] || eex_opts[:caller].function
    function_tag = "#{func_name}/#{func_arity}"
    incremental_class_names = Map.get(class_tree_context, :class_names, [])
    incremental_mappings = Map.get(class_tree_context, :class_mappings, %{})
    class_names_for_function = Map.get(incremental_mappings, function_tag, []) ++ class_names
    class_mappings = Enum.uniq(class_names_for_function)

    class_names =
      class_mappings
      |> Enum.concat(incremental_class_names)
      |> Enum.uniq()

    class_tree_context
    |> Map.put(:class_names, class_names)
    |> put_in([:class_mappings, function_tag], class_mappings)
    |> persist_to_class_tree()
  end

  defp class_tree_context(platform_id, template_module, eex_opts) do
    compiled_at = eex_opts[:compiled_at]
    filename = class_tree_filename(platform_id, template_module)

    with {:ok, body} <- File.read(filename),
         {:ok, %{} = class_tree} <- Jason.decode(body) do
      class_mappings = class_tree["class_mappings"] || %{}
      class_names = Map.values(class_mappings)

      %{
        class_mappings: class_mappings,
        class_names: List.flatten(class_names),
        meta: %{
          compiled_at: compiled_at,
          filename: get_in(class_tree, ["meta", "filename"]) || filename
        }
      }
    else
      _ ->
        %{
          class_mappings: %{},
          class_names: [],
          meta: %{
            compiled_at: compiled_at,
            filename: filename
          }
        }
    end
  end

  defp class_tree_filename(platform_id, template_module) do
    "#{:code.lib_dir(:live_view_native)}/.lvn/#{platform_id}/#{template_module}.classtree.json"
  end

  defp dump_class_tree_bytecode(class_tree_map, caller) do
    generate_class_tree_module(class_tree_map, caller)
  end

  defp generate_class_tree_module(class_tree_map, caller) do
    %Macro.Env{module: template_module, requires: requires} = caller
    module_name = generate_class_tree_module_name(template_module)
    branches = get_class_tree_branches(requires)

    ast = quote location: :keep do
      def class_tree(stylesheet_key) do
        %{
          branches: unquote(branches),
          contents: unquote(Macro.escape(class_tree_map))[stylesheet_key],
          expanded_branches: [unquote(module_name)]
        } ||
          %{
            branches: [],
            contents: %{},
            expanded_branches: [unquote(module_name)]
          }
      end
    end

    Module.create(module_name, ast, Macro.Env.location(__ENV__))

    :ok
  end

  defp generate_class_tree_module_name(module) do
    Module.concat([LiveViewNative, Internal, ClassTree, module])
  end

  defp get_class_tree_branches(requires) do
    requires
    |> Enum.filter(&module_has_stylesheet?/1)
    |> Enum.map(&generate_class_tree_module_name/1)
  end

  defp extract_all_class_names(doc) do
    doc
    |> Floki.traverse_and_update(%{}, &extract_class_names/2)
    |> elem(1)
    |> Map.keys()
  end

  defp extract_class_names(node, acc) do
    new_acc =
      node
      |> Floki.attribute("class")
      |> split_class_names()
      |> Enum.reduce(acc, fn(class_name, acc) ->
        Map.put(acc, class_name, true)
      end)

      {nil, new_acc}
  end

  defp split_class_names([]), do: []
  defp split_class_names([class_names | _tail]) do
    String.split(class_names, " ", trim: true)
  end

  defp module_has_stylesheet?(module) do
    :functions
    |> module.__info__()
    |> Enum.member?({:__compiled_stylesheet__, 1})
  end

  defp persist_to_class_tree(%{meta: %{filename: filename}} = class_tree) do
    with {:ok, encoded_tree} <- Jason.encode(class_tree),
         dirname <- Path.dirname(filename),
         :ok <- File.mkdir_p(dirname),
         :ok <- File.touch(filename),
         :ok <- File.write(filename, encoded_tree) do
      class_tree
    else
      error ->
        raise "TODO: Handle error #{error}"
    end
  end
end