Skip to main content

lib/mix/tasks/shadix.add.ex

defmodule Mix.Tasks.Shadix.Add do
  @shortdoc "Copies Shadix components into your app, rewriting module namespaces"

  @moduledoc """
  Copies one or more Shadix components into your Phoenix application,
  rewriting `Shadix.*` module namespaces to match your app.

  ## Usage

      mix shadix.add button card alert

  ## Options

    * `--namespace` — target base module namespace
      (default: `<AppModule>Web.Components.UI`)
    * `--dir` — target directory to write files into
      (default: `lib/<underscored_namespace>`)
    * `--hooks-dir` — target directory for JS/TS LiveView hook files
      (default: `assets/js/hooks`)
    * `--force` — overwrite existing files (default: false, skip existing)

  ## What it does

  1. Resolves transitive dependencies (e.g. `button` pulls in `cn`)
  2. Rewrites `Shadix.Components` → `<namespace>` and `Shadix.Cn` → `<namespace>.Cn`
  3. Writes files to the target directory
  4. Prints a summary and a reminder to run `mix shadix.init` if not done yet
  """

  use Mix.Task

  @requirements ["app.config"]

  @impl Mix.Task
  def run(args) do
    {opts, names, _invalid} =
      OptionParser.parse(args,
        strict: [namespace: :string, dir: :string, hooks_dir: :string, force: :boolean]
      )

    if names == [] do
      Mix.raise("Usage: mix shadix.add <component> [component...] [options]")
    end

    namespace = Keyword.get(opts, :namespace) || Shadix.Generator.default_namespace()
    dir = Keyword.get(opts, :dir) || Shadix.Generator.default_dir(namespace)
    hooks_dir = Keyword.get(opts, :hooks_dir, "assets/js/hooks")
    force = Keyword.get(opts, :force, false)

    File.mkdir_p!(dir)

    manifests = Shadix.Registry.resolve(names)

    written =
      Enum.flat_map(manifests, fn manifest ->
        Enum.flat_map(manifest["files"], fn file ->
          dest = Path.join(dir, file["path"])
          content = Shadix.Generator.rewrite_namespace(file["content"], namespace)
          maybe_write(dest, content, force)
        end)
      end)

    # Hook sources are copied verbatim — they are NOT namespace-rewritten (TS, not Elixir).
    hooks = Enum.flat_map(manifests, fn manifest -> manifest["hooks"] || [] end)

    written_hooks =
      if hooks == [] do
        []
      else
        File.mkdir_p!(hooks_dir)

        Enum.flat_map(hooks, fn hook ->
          dest = Path.join(hooks_dir, hook["path"])
          maybe_write(dest, hook["content"], force)
        end)
      end

    Mix.shell().info("")

    if written == [] and written_hooks == [] do
      Mix.shell().info("No files written (all already existed).")
    else
      if written != [] do
        Mix.shell().info("#{length(written)} file(s) written to #{dir}/")
      end

      if written_hooks != [] do
        Mix.shell().info("#{length(written_hooks)} hook file(s) written to #{hooks_dir}/")
      end
    end

    Mix.shell().info("""

    Run `mix shadix.init` if you haven't already to install the theme CSS
    and configure Tailwind.
    """)

    maybe_print_hook_notes(hooks)

    npm_deps =
      manifests
      |> Enum.flat_map(fn manifest -> manifest["npm_deps"] || [] end)
      |> Enum.uniq()

    maybe_print_npm_notes(npm_deps)
  end

  # Writes `content` to `dest`, skipping pre-existing files unless `force`.
  # Returns `[dest]` if written, `[]` otherwise (for flat_map accumulation).
  defp maybe_write(dest, content, force) do
    if File.exists?(dest) and not force do
      Mix.shell().info("  skip  #{dest} (already exists; use --force to overwrite)")
      []
    else
      File.write!(dest, content)
      Mix.shell().info("  write #{dest}")
      [dest]
    end
  end

  defp maybe_print_hook_notes([]), do: :ok

  defp maybe_print_hook_notes(hooks) do
    names = hooks |> Enum.map(& &1["name"]) |> Enum.uniq()

    Mix.shell().info("""
    This component ships LiveView hook(s): #{Enum.join(names, ", ")}.

    Import and register them in your LiveSocket `hooks:` map, e.g.:

        import { #{Enum.join(names, ", ")} } from "./hooks/dialog"

        const liveSocket = new LiveSocket("/live", Socket, {
          hooks: { #{Enum.join(names, ", ")} },
          // ...
        })
    """)
  end

  defp maybe_print_npm_notes([]), do: :ok

  defp maybe_print_npm_notes(deps) do
    Mix.shell().info("""
    This component depends on npm package(s). Install them in your assets dir:

        npm install #{Enum.join(deps, " ")}
    """)
  end
end