lib/pyro/overrides.ex

##############################################################################
####    O R I G I N A L    L I C E N S E
##############################################################################

# Copyright 2022 Alembic Pty Ltd.

# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

##############################################################################
####    M O D I F I C A T I O N    N O T I C E
##############################################################################

# Original file: overrides.ex from AshAuthenticationPhoenix (https://github.com/team-alembic/ash_authentication_phoenix/blob/main/lib/ash_authentication_phoenix/overrides.ex)
# Modifications: Changed almost everything, but the basic idea is very much the same.
# Copyright 2023 Frank Dugan III
# Licensed under the MIT license

defmodule Pyro.Overrides do
  @moduledoc """
  The overrides system provides out-of-the-box presets while also enabling deep customization of Pyro components.

  The `Pyro.Overrides.Default` preset is a great example to dig in and see how the override system works. A `Pyro.Component` flags attrs with `overridable`, then leverages [`assign_overridables/1`](`Pyro.Component.assign_overridables/1`) to reference overrides set in these presets/custom override modules and load them as defaults.

  Pyro defaults to the following overrides:

  ```
  [Pyro.Overrides.Default]
  ```

  But you probably want to customize at least a few overrides. To do so, configure your app with:

  ```
  config :pyro, :overrides,
    [MyApp.CustomOverrides, Pyro.Overrides.Default]
  ```

  Then, define your overrides in your custom module:

  ```
  defmodule MyApp.CustomOverrides do
    @moduledoc false
    use Pyro.Overrides

    override Core, :back do
      set :class, "text-lg font-black"
      set :icon_kind, :outline
      set :icon_name, :arrow_left
    end
  end
  ```

  The overrides will be merged left-to-right, returning the value in the *first* module that sets a given key. So in the above example, the `<Core.back>` component will have an `icon_name` default of `:arrow_left`, since the `MyApp.CustomOverrides` was the first module in the list to provide that key. But the `icon_class` was unspecified in the custom module, so it will return the value from `Pyro.Overrides.Default` since it is provided there:

  - You only need to define what you want to override from the other defaults
  - You can use any number of `:overrides` modules, though it is probably best to only use only 1-3 to keep things simple/efficient
  - If no modules define the value, it will simply be `nil`
  - If [`assign_overridables/1`](`Pyro.Component.assign_overridables/1`) is called on the component with the `required: true` attr option, an error will be raised if no configured overrides define a default
  """

  @doc false
  @spec __using__(any) :: Macro.t()
  defmacro __using__(env) do
    global_style = env[:global_style]
    makeup_light = env[:makeup_light]
    makeup_dark = env[:makeup_dark]

    quote do
      require unquote(__MODULE__)
      import unquote(__MODULE__), only: :macros
      import Pyro.Component.Helpers

      alias Pyro.Components.{
        Autocomplete,
        Core,
        Extra,
        SmartDataTable,
        SmartForm
      }

      alias Pyro.Resource.Info, as: UI
      alias Phoenix.LiveView.JS

      Module.register_attribute(__MODULE__, :override, accumulate: true)
      @component nil
      @global_style unquote(global_style)
      @makeup_light unquote(makeup_light)
      @makeup_dark unquote(makeup_dark)
      @__pass_assigns_to__ %{}

      @on_definition unquote(__MODULE__)
      @before_compile unquote(__MODULE__)

      @doc false
      # Internally used for validation.
      @spec __pass_assigns_to__ :: map()
      def __pass_assigns_to__(), do: @__pass_assigns_to__

      @doc false
      # Internally used for asset generation.
      @spec global_style :: binary() | nil
      def global_style(), do: @global_style

      @doc false
      # Internally used for asset generation.
      @spec makeup_light :: any()
      def makeup_light(), do: @makeup_light

      @doc false
      # Internally used for asset generation.
      @spec makeup_dark :: any()
      def makeup_dark(), do: @makeup_dark
    end
  end

  @doc """
  Define overrides for a specific component.

  You need to specify the module and function name of the component to override.

  ## Examples

      override Core, :back do
        set :class, "text-lg font-black"
      end
  """
  @doc type: :macro
  @spec override(module :: module, component :: atom, do: Macro.t()) :: Macro.t()
  defmacro override(module, component, do: block) do
    quote do
      @component {unquote(module), unquote(component)}
      unquote(block)
    end
  end

  @doc """
  Override a setting within a component.

  Value can be:

  * A literal value matching the type of the prop being overridden
  * A function capture of arity 1 with the argument `passed_assigns`, which is executed at runtime and is passed the component's `assigns`
  * Any other function capture, which will simply be passed along as a literal

  The `passed_assigns` function capture allows for complex conditionals. For examples of this, please view the source of `Pyro.Overrides.Default`.

  > #### Tip: {: .info}
  >
  > Be sure to include the module in the function capture, since this is a macro and will lose the reference otherwise.

  ## Examples

      set :class, "text-lg font-black"
      set :class, &__MODULE__.back_class/1
      # ...
      def back_class(passed_assigns) do
  """
  @doc type: :macro
  @spec set(atom, any) :: Macro.t()
  defmacro set(selector, value) do
    quote do
      @override {@component, unquote(selector), unquote(value)}
    end
  end

  @doc false
  def __on_definition__(env, kind, name, args, _guards, _body) do
    if kind == :def && length(args) == 1 &&
         Enum.find(args, fn {arg, _line, _} -> arg == :passed_assigns end) do
      pass_assigns_to =
        env.module
        |> Module.get_attribute(:__pass_assigns_to__)
        |> Map.put(name, true)

      append =
        "This override is passed component assigns and executed while being assigned at runtime."

      docs =
        case Module.get_attribute(env.module, :doc) do
          false ->
            append

          nil ->
            append

          {_line, docs} ->
            docs <> "\n" <> append
        end

      Module.put_attribute(env.module, :doc, {env.line, docs})
      Module.put_attribute(env.module, :__pass_assigns_to__, pass_assigns_to)
    end

    :ok
  end

  @doc false
  @spec __before_compile__(any) :: Macro.t()
  defmacro __before_compile__(env) do
    overrides =
      env.module
      |> Module.get_attribute(:override, [])
      |> Enum.map(fn
        {component, selector, value} = override when is_function(value, 1) ->
          name =
            value
            |> Function.info()
            |> Keyword.get(:name)

          if Map.get(Module.get_attribute(env.module, :__pass_assigns_to__), name) do
            {component, selector, {:pass_assigns_to, value}}
          else
            override
          end

        override ->
          override
      end)

    env.module
    |> Module.put_attribute(:override, overrides)

    overrides =
      Map.new(overrides, fn {component, selector, value} -> {{component, selector}, value} end)

    makeup =
      if Module.get_attribute(env.module, :makeup_light) ||
           Module.get_attribute(env.module, :makeup_dark) do
        """
        ## Makeup

        """ <>
          case Module.get_attribute(env.module, :makeup_light) do
            style when is_function(style, 0) ->
              module = Function.info(style)[:module]
              name = Function.info(style)[:name]
              "* `makeup_light`: [`#{name}/0`](`#{module}.#{name}/0`)\n"

            _ ->
              ""
          end <>
          case Module.get_attribute(env.module, :makeup_dark) do
            style when is_function(style, 0) ->
              module = Function.info(style)[:module]
              name = Function.info(style)[:name]
              "* `makeup_dark`: [`#{name}/0`](`#{module}.#{name}/0`)\n"

            _ ->
              ""
          end
      else
        ""
      end

    global_style =
      case Module.get_attribute(env.module, :global_style) do
        style when is_binary(style) ->
          """
          ## Global Style

          ```css
          #{style}
          ```
          """

        _ ->
          ""
      end

    override_docs = """
    - Captured functions with arity 1 and the arg named `passed_assigns` are passed component assigns at runtime, allowing complex conditional logic
    - [`assign_overridables/1`](`Pyro.Component.assign_overridables/1`) preserves the definition order of attrs and assigns them in that order, preserving dependency chains
    - Attrs with type `:tails_classes` utilize `Tails`, and are merged by the component to prevent weird precedence conflicts and HTML bloat

    #{makeup}
    #{global_style}

    ## Overrides

    #{overrides |> Enum.group_by(fn {{component, _}, _} -> component end) |> Enum.map(fn {{module, component}, overrides} -> """
      - `#{module}.#{component}/1`
      #{Enum.map_join(overrides, "\n", fn {{_, selector}, value} ->
        value = case value do
          {:pass_assigns_to, value} -> value |> inspect |> String.replace("&", "")
          value when is_function(value) -> value |> inspect |> String.replace("&", "")
          value -> inspect(value)
        end
        "  - `:#{selector}` `#{value}`"
      end)}
      """ end) |> Enum.join("\n")}
    """

    quote do
      @moduledoc (case @moduledoc do
                    false ->
                      false

                    nil ->
                      name =
                        __MODULE__
                        |> Module.split()
                        |> List.last()

                      "A #{name} override theme." <> "\n" <> unquote(override_docs)

                    docs ->
                      docs <> "\n" <> unquote(override_docs)
                  end)

      @doc false
      # Internally used to collect overrides.
      def overrides do
        unquote(Macro.escape(overrides))
      end
    end
  end

  @doc false
  # Internally used for asset generation.
  @spec global_style :: binary() | nil
  def global_style do
    configured_overrides()
    |> Enum.reduce_while(nil, fn module, _ ->
      case Code.ensure_compiled(module) do
        {:module, _} ->
          module.global_style()
          |> case do
            value when is_binary(value) -> {:halt, value}
            nil -> {:cont, nil}
          end

        {:error, _} ->
          {:cont, nil}
      end
    end)
  end

  @doc false
  # Internally used for asset generation.
  @spec makeup_theme :: map()
  def makeup_theme do
    light =
      configured_overrides()
      |> Enum.reduce_while(nil, fn module, _ ->
        case Code.ensure_compiled(module) do
          {:module, _} ->
            module.makeup_light()
            |> case do
              value when is_function(value, 0) -> {:halt, value}
              nil -> {:cont, nil}
            end

          {:error, _} ->
            {:cont, nil}
        end
      end)

    dark =
      configured_overrides()
      |> Enum.reduce_while(nil, fn module, _ ->
        case Code.ensure_compiled(module) do
          {:module, _} ->
            module.makeup_dark()
            |> case do
              value when is_function(value, 0) -> {:halt, value}
              nil -> {:cont, nil}
            end

          {:error, _} ->
            {:cont, nil}
        end
      end)

    %{light: light, dark: dark}
  end

  @doc """
  Get an override value for a given component prop.
  """
  @spec override_for(module, atom, atom) :: any
  def override_for(module, component, prop) do
    configured_overrides()
    |> Enum.reduce_while(nil, fn override_module, _ ->
      case Code.ensure_compiled(module) do
        {:module, _} ->
          override_module.overrides()
          |> Map.fetch({{module, component}, prop})
          |> case do
            {:ok, value} -> {:halt, value}
            :error -> {:cont, nil}
          end

        {:error, _} ->
          {:cont, nil}
      end
    end)
  end

  @doc """
  Get the configured or default override modules.
  """
  @spec configured_overrides() :: [module]
  def configured_overrides() do
    Application.get_env(:pyro, :overrides, [__MODULE__.Default])
  end
end