lib/phx_live_storybook.ex

defmodule PhxLiveStorybook.BackendBehaviour do
  @moduledoc """
  Behaviour implemented by your backend module.
  """

  alias PhxLiveStorybook.{FolderEntry, StoryEntry}

  @doc """
  Returns a configuration value from your config.exs storybook settings.

  `key` is the config key
  `default` is an optional default value if no value can be fetched.
  """
  @callback config(key :: atom(), default :: any()) :: any()

  @doc """
  Returns a precompiled tree of your storybook stories.
  """
  @callback content_tree() :: [%FolderEntry{} | %StoryEntry{}]

  @doc """
  Returns all the leaves (only stories) of the storybook content tree.
  """
  @callback leaves() :: [%StoryEntry{}]

  @doc """
  Returns all the nodes (stoires & folders) of the storybook content tree as a flat list.
  """
  @callback flat_list() :: [%FolderEntry{} | %StoryEntry{}]

  @doc """
  Returns an entry from its absolute storybook path (not filesystem).
  """
  @callback find_entry_by_path(String.t()) :: %FolderEntry{} | %StoryEntry{}

  @doc """
  Retuns a storybook path from a story module.
  """
  @callback storybook_path(atom()) :: String.t()
end

defmodule PhxLiveStorybook do
  @external_resource "README.md"
  @moduledoc @external_resource
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  alias PhxLiveStorybook.Entries
  alias PhxLiveStorybook.ExsCompiler
  alias PhxLiveStorybook.Stories.StoryValidator

  require Logger

  @doc false
  defmacro __using__(opts) do
    {opts, _} = Code.eval_quoted(opts, [], __CALLER__)
    opts = merge_opts_and_config(opts, __CALLER__.module)
    content_tree = content_tree(opts)

    [
      main_quote(opts),
      recompilation_quotes(opts),
      story_compilation_quotes(opts, content_tree),
      config_quotes(opts),
      stories_quotes(opts, content_tree)
    ]
  end

  defp merge_opts_and_config(opts, backend_module) do
    config_opts = Application.get_env(opts[:otp_app], backend_module, [])
    Keyword.merge(opts, config_opts)
  end

  defp main_quote(opts) do
    quote do
      @behaviour PhxLiveStorybook.BackendBehaviour

      @impl PhxLiveStorybook.BackendBehaviour
      def storybook_path(story_module) do
        if Code.ensure_loaded?(story_module) do
          content_path = Keyword.get(unquote(opts), :content_path)

          file_path =
            story_module.__file_path__()
            |> String.replace_prefix(content_path, "")
            |> String.replace_suffix(Entries.story_file_suffix(), "")
        end
      end
    end
  end

  defp story_compilation_quotes(opts, content_tree) do
    content_path = Keyword.get(opts, :content_path)

    case compilation_mode(opts) do
      :lazy ->
        quote do
          def load_story(story_path) do
            story_path = String.replace_prefix(story_path, "/", "")
            story_path = story_path <> Entries.story_file_suffix()

            case ExsCompiler.compile_exs(story_path, unquote(content_path)) do
              {:ok, story} -> StoryValidator.validate(story)
              {:error, message, exception} -> {:error, message, exception}
            end
          end
        end

      :eager ->
        quotes =
          for story_entry <- Entries.leaves(content_tree) do
            story_name = String.replace_prefix(story_entry.path, "/", "")
            story_path = story_name <> Entries.story_file_suffix()

            story =
              story_path
              |> ExsCompiler.compile_exs!(content_path)
              |> StoryValidator.validate!()

            quote do
              @external_resource Path.join(unquote(content_path), unquote(story_path))
              def load_story(unquote(story_name)) do
                {:ok, unquote(story)}
              end
            end
          end

        quotes ++
          [
            quote do
              def load_story(_), do: {:error, :not_found}
            end
          ]
    end
  end

  # This quote triggers recompilation for the module whenever a new file or any index file under
  # content_path has been touched.
  defp recompilation_quotes(opts) do
    content_path =
      Keyword.get_lazy(opts, :content_path, fn -> raise "content_path key must be set" end)

    components_pattern = Path.join(content_path, "**/*")
    index_pattern = Path.join(content_path, "**/*#{Entries.index_file_suffix()}")

    quote do
      @index_paths Path.wildcard(unquote(index_pattern))
      @paths Path.wildcard(unquote(components_pattern))
      @paths_hash :erlang.md5(@paths)

      for index_path <- @index_paths do
        @external_resource index_path
      end

      def __mix_recompile__? do
        unquote(components_pattern) |> Path.wildcard() |> :erlang.md5() !=
          @paths_hash
      end
    end
  end

  @doc false
  defp stories_quotes(_opts, content_tree) do
    entries = Entries.flat_list(content_tree)
    leaves = Entries.leaves(content_tree)

    find_entry_by_path_quotes =
      for entry <- entries do
        quote do
          @impl PhxLiveStorybook.BackendBehaviour
          def find_entry_by_path(unquote(entry.path)) do
            unquote(Macro.escape(entry))
          end
        end
      end

    single_quote =
      quote do
        @impl PhxLiveStorybook.BackendBehaviour
        def find_entry_by_path(_), do: nil

        @impl PhxLiveStorybook.BackendBehaviour
        def content_tree, do: unquote(Macro.escape(content_tree))

        @impl PhxLiveStorybook.BackendBehaviour
        def leaves, do: unquote(Macro.escape(Entries.leaves(leaves)))

        @impl PhxLiveStorybook.BackendBehaviour
        def flat_list, do: unquote(Macro.escape(entries))
      end

    find_entry_by_path_quotes ++ [single_quote]
  end

  defp content_tree(opts) do
    content_path = Keyword.get(opts, :content_path)
    folders_config = Keyword.get(opts, :folders, [])
    Entries.content_tree(content_path, folders_config)
  end

  defp compilation_mode(opts) do
    case Keyword.get(opts, :compilation_mode) do
      mode when mode in [:lazy, :eager] ->
        mode

      _ ->
        if Code.ensure_loaded?(Mix) and Mix.env() == :dev do
          :lazy
        else
          :eager
        end
    end
  end

  @doc false
  defp config_quotes(opts) do
    quote do
      @impl PhxLiveStorybook.BackendBehaviour
      def config(key, default \\ nil) do
        Keyword.get(unquote(opts), key, default)
      end
    end
  end
end