Skip to main content

lib/mix/tasks/shadix.gen.registry.ex

defmodule Mix.Tasks.Shadix.Gen.Registry do
  @shortdoc "Regenerates priv/registry/*.json from canonical Shadix sources"

  @moduledoc """
  Reads `lib/shadix/cn.ex`, `lib/shadix/form.ex`, and all
  `lib/shadix/components/*.ex`, then writes `priv/registry/<name>.json`
  (one per component, plus one each for `cn` and `form`).

  Hooks and npm_deps are auto-detected by convention:
  - Any `phx-hook="ShadixFoo"` in a component's source implies a hook whose
    TypeScript source lives at `assets/ts/<snake>.ts` (where `<snake>` is the
    underscored form of `Foo`, e.g. `ShadixDropdownMenu` → `dropdown_menu.ts`).
  - npm_deps are inferred by scanning each detected hook's `.ts` content for
    bare package imports (`from "<pkg>"` where `<pkg>` doesn't start with `.`
    or `/`).

  Adding a new component with a hook requires no changes here — just place the
  `.ex` file and the matching `assets/ts/<snake>.ts`.

  ## Usage

      mix shadix.gen.registry

  ## Options

    * `--out` — override the output directory (default: `priv/registry`)
  """

  use Mix.Task

  @requirements ["app.config"]

  @doc false
  @impl Mix.Task
  def run(args) do
    {opts, _rest, _invalid} =
      OptionParser.parse(args, strict: [out: :string])

    out_dir = Keyword.get(opts, :out, "priv/registry")
    File.mkdir_p!(out_dir)

    # Collect source files: cn first, then form, then all components
    cn_path = "lib/shadix/cn.ex"
    form_path = "lib/shadix/form.ex"
    component_paths = Path.wildcard("lib/shadix/components/*.ex") |> Enum.sort()

    sources =
      [{cn_path, "cn"}, {form_path, "form"}] ++
        Enum.map(component_paths, fn p -> {p, name_from_path(p)} end)

    Enum.each(sources, fn {source_path, name} ->
      content = File.read!(source_path)
      deps = deps_for(name, content)
      hooks = hooks_for(name, content)
      npm_deps = npm_deps_from_hooks(hooks)

      manifest = %{
        "name" => name,
        "registry_deps" => deps,
        "npm_deps" => npm_deps,
        "files" => [
          %{
            "path" => "#{name}.ex",
            "content" => content
          }
        ],
        "hooks" => hooks
      }

      json = Jason.encode!(manifest, pretty: true)
      out_path = Path.join(out_dir, "#{name}.json")
      File.write!(out_path, json)
      Mix.shell().info("Generated #{out_path}")
    end)

    Mix.shell().info("Done. #{length(sources)} manifest(s) written to #{out_dir}/")
  end

  # Derives the component name from its file path.
  # e.g. "lib/shadix/components/button.ex" -> "button"
  defp name_from_path(path) do
    path
    |> Path.basename(".ex")
  end

  # Determines the registry_deps for a given entry.
  # - "cn"  → no deps
  # - "form" → no deps (form.ex does not import Shadix.Cn)
  # - components → always depend on "cn"; also depend on "form" when they
  #   import Shadix.Form
  defp deps_for("cn", _content), do: []
  defp deps_for("form", _content), do: []

  defp deps_for(_name, content) do
    base = ["cn"]

    if content =~ "import Shadix.Form" do
      base ++ ["form"]
    else
      base
    end
  end

  # Auto-detects hooks by scanning the component source for phx-hook="ShadixXxx"
  # occurrences. For each distinct hook name, the corresponding TS file is
  # assets/ts/<snake>.ts where <snake> = Macro.underscore of the part after "Shadix".
  # Raises if the expected TS file does not exist (catches typos early).
  defp hooks_for(_name, content) do
    hook_names =
      Regex.scan(~r/phx-hook="(Shadix\w+)"/, content)
      |> Enum.map(fn [_full, hook_name] -> hook_name end)
      |> Enum.uniq()

    Enum.map(hook_names, fn hook_name ->
      # Strip "Shadix" prefix and underscore-ify the rest
      pascal = String.replace_prefix(hook_name, "Shadix", "")
      snake = Macro.underscore(pascal)
      ts_path = "assets/ts/#{snake}.ts"

      unless File.exists?(ts_path) do
        Mix.raise(
          "Hook #{hook_name} detected in component source, but #{ts_path} does not exist. " <>
            "Create the file or fix the phx-hook attribute."
        )
      end

      %{
        "path" => "#{snake}.ts",
        "name" => hook_name,
        "content" => File.read!(ts_path)
      }
    end)
  end

  # Scans hook TS content for bare package imports (e.g. `from "@floating-ui/dom"`)
  # and returns the deduplicated list of package names. Relative/absolute imports
  # (starting with `.` or `/`) are excluded.
  defp npm_deps_from_hooks(hooks) do
    hooks
    |> Enum.flat_map(fn %{"content" => ts_content} ->
      Regex.scan(~r/from ['"]([^'"]+)['"]/, ts_content)
      |> Enum.map(fn [_full, pkg] -> pkg end)
      |> Enum.reject(&String.starts_with?(&1, [".", "/"]))
    end)
    |> Enum.uniq()
  end
end