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