lib/fermo/assets.ex

defmodule Fermo.Assets do
  @moduledoc """
  Loads the asset manifest and provides helpers to build
  paths to the assets.
  """

  use GenServer

  @asset_path Application.compile_env(
    :fermo,
    :asset_path,
    "build"
  )
  @asset_extensions Application.compile_env(
    :fermo,
    :asset_extensions,
    ~w(.css .ico .jpg .jpeg .js .png .txt)
  )
  @digested_filename ~r(-[a-z0-9]{32}\.[a-z0-9]+$)
  @live_asset_base Application.compile_env(
    :fermo,
    :live_asset_base,
    "/"
  )
  @name :fermo_assets

  def start_link(args \\ %{}) do
    GenServer.start_link(__MODULE__, args, name: @name)
  end

  @impl true
  def init(args) do
    {:ok, args}
  end

  def create_manifest() do
    with {:ok, files} <- list_files(),
         {:ok, metadata} <- build_metadata(files),
         {:ok} <- copy_to_digested(metadata),
         {:ok, manifest} <- to_manifest(metadata) do
      GenServer.call(@name, {:put, manifest})
      {:ok}
    else
      {:error, reason} ->
        raise reason
    end
  end

  defp list_files do
    files =
      @asset_path
      |> Path.join("**")
      |> Path.wildcard()
      |> Enum.filter(&is_asset?/1)
    {:ok, files}
  end

  defp is_asset?(path) do
    with false <- File.dir?(path),
         filename <- Path.basename(path),
         false <- Regex.match?(@digested_filename, filename),
         extension <- Path.extname(path),
         true <- extension in @asset_extensions do
      true
    else
      _ -> false
    end
  end

  defp build_metadata(files) do
    metadata =
      files
      |> Enum.map(fn file ->
        relative_filename = Path.relative_to(file, @asset_path)
        content = File.read!(file)
        digest = Base.encode16(:erlang.md5(content), case: :lower)
        extension = Path.extname(file)
        root = Path.rootname(file, extension)
        digested_filename = "#{root}-#{digest}#{extension}"
        relative_digested_filename = Path.relative_to(digested_filename, @asset_path)
        %{
          filename: file,
          relative_filename: relative_filename,
          digest: digest,
          digested_filename: digested_filename,
          asset_path: "/#{relative_digested_filename}",
          extension: extension
        }
      end)
    {:ok, metadata}
  end

  defp copy_to_digested(metadata) do
    metadata
    |> Enum.each(fn item ->
      File.cp!(item.filename, item.digested_filename)
    end)
    {:ok}
  end

  defp to_manifest(metadata) do
    manifest =
      metadata
      |> Enum.map(fn item ->
        {item.relative_filename, item}
      end)
      |> Enum.into(%{})
    {:ok, manifest}
  end

  def manifest do
    GenServer.call(@name, {:manifest})
  end

  def path("/" <> name) do
    GenServer.call(@name, {:path, name})
  end

  def path(name) do
    GenServer.call(@name, {:path, name})
  end

  def path!(name) do
    {:ok, path} = path(name)
    path
  end

  @impl true
  def handle_call({:put, state}, _from, _state) do
    {:reply, {:ok}, state}
  end

  def handle_call({:manifest}, _from, state) do
    {:reply, {:ok, state}, state}
  end

  def handle_call({:path, name}, _from, state) do
    if Map.has_key?(state, name) do
      item = state[name]
      {:reply, {:ok, item.asset_path}, state}
    else
      {:reply, {:error, "'#{name}' not found in manifest"}, state}
    end
  end

  defmacro asset_path(name) do
    quote do
      context = var!(context)
      if context[:page][:live] do
        live_asset_path(unquote(name))
      else
        static_asset_path(unquote(name))
      end
    end
  end

  def static_asset_path("https://" <> _path = url) do
    url
  end

  def static_asset_path(filename) do
    path!(filename)
  end

  def live_asset_path(filename) do
    manifest_path = path!(filename)
    Path.join(@live_asset_base, manifest_path)
  end

  # TODO: make this a context aware macro
  def font_path("https://" <> _path = url) do
    url
  end
  def font_path(filename) do
    path!("/fonts/#{filename}")
  end

  defmacro image_path("https://" <> _path = url) do
    quote do
      static_image_path(unquote(url))
    end
  end
  defmacro image_path(name) do
    quote do
      context = var!(context)
      if context[:page][:live] do
        live_image_path(unquote(name))
      else
        static_image_path(unquote(name))
      end
    end
  end

  defmacro image_tag(filename, attributes) do
    quote do
      if String.starts_with?(unquote(filename), "https://") do
        image_tag_with_attributes(unquote(filename), unquote(attributes))
      else
        context = var!(context)
        url = if context[:page][:live] do
          live_image_path(unquote(filename))
        else
          static_image_path(unquote(filename))
        end
        image_tag_with_attributes(url, unquote(attributes))
      end
    end
  end

  def image_tag_with_attributes(url, attributes) do
    attribs = Enum.map(attributes, fn ({k, v}) ->
      "#{k}=\"#{v}\""
    end)

    "<img src=\"#{url}\" #{Enum.join(attribs, " ")}/>"
  end

  def static_image_path("https://" <> _path = url) do
    url
  end
  def static_image_path("/" <> filename) do
    path!("/images/#{filename}")
  end
  def static_image_path(filename) do
    path!("/images/#{filename}")
  end

  def live_image_path(filename) do
    live_asset_path("images/#{filename}")
  end

  defmacro javascript_path(name) do
    quote do
      context = var!(context)
      if context[:page][:live] do
        live_javascript_path(unquote(name))
      else
        static_javascript_path(unquote(name))
      end
    end
  end

  # TODO: handle user-supplied attributes, e.g. defer="true"
  defmacro javascript_include_tag(name) do
    quote do
      context = var!(context)
      url = if context[:page][:live] do
        live_javascript_path(unquote(name))
      else
        static_javascript_path(unquote(name))
      end
      "<script src=\"#{url}\" type=\"text/javascript\"></script>"
    end
  end

  def static_javascript_path("https://" <> _path = url) do
    url
  end
  def static_javascript_path(name) do
    path!("/#{name}.js")
  end

  def live_javascript_path(name) do
    live_asset_path("/#{name}.js")
  end

  defmacro stylesheet_path(name) do
    quote do
      context = var!(context)
      if context[:page][:live] do
        live_stylesheet_path(unquote(name))
      else
        static_stylesheet_path(unquote(name))
      end
    end
  end

  defmacro stylesheet_link_tag(name) do
    quote do
      context = var!(context)
      url = if context[:page][:live] do
        live_stylesheet_path(unquote(name))
      else
        static_stylesheet_path(unquote(name))
      end
      "<link href=\"#{url}\" media=\"all\" rel=\"stylesheet\" />"
    end
  end

  def static_stylesheet_path("https://" <> _path = url) do
    url
  end
  def static_stylesheet_path(name) do
    path!("/#{name}.css")
  end

  def live_stylesheet_path(name) do
    live_asset_path("/#{name}.css")
  end
end