defmodule Mix.Tasks.Salad.Init do @moduledoc """ A Mix task for initializing SaladUI in a project and configuring it to use a color theme. Commands: mix salad.init : Add all necessary configuration to be able to install SaladUI components mix salad.init --as-lib : Add all necessary configuration to use SaladUI in a library mix salad.init --help : Print this help message """ use Mix.Task import SaladUi.TasksHelpers alias SaladUI.Patcher @default_components_path "lib/%APP_NAME%_web/components" @color_schemes ~w(zinc slate stone gray neutral red rose orange green blue yellow violet) @default_color_scheme "gray" @default_tailwind_animate_version "1.0.7" @impl true def run(argv) do case argv do [] -> execute_init() ["--as-lib"] -> execute_init(as_lib: true) _ -> print_usage() end end defp execute_init(opts \\ []) do component_path = prompt_component_path() color_scheme = prompt_color_scheme() init(component_path, color_scheme, opts) end defp init(component_path, color_scheme, opts) do env = Atom.to_string(Mix.env()) app_name = Mix.Project.config()[:app] |> Atom.to_string() |> String.downcase() assets_path = build_assets_path(env) application_file_path = Path.join(File.cwd!(), "lib/#{app_name}/application.ex") File.mkdir_p!(component_path) with :ok <- write_config(component_path), :ok <- init_tw_merge_cache(application_file_path), :ok <- patch_css(color_scheme, assets_path), :ok <- patch_js(assets_path), :ok <- copy_tailwind_colors(assets_path), :ok <- patch_tailwind_config(opts), :ok <- maybe_write_helpers_module(component_path, app_name, opts), :ok <- maybe_write_component_module(component_path, app_name, opts), :ok <- install_tailwind_animate(opts) do if opts[:as_lib] do Mix.shell().info("Done. Now you can use any component by `import SaladUI.` in your project.") else Mix.shell().info("Done. Now you can add components by running mix salad.add ") end else {:error, reason} -> Mix.shell().error("Error during setup: #{reason}") end end defp prompt_component_path do default_path = build_default_component_path() "Enter the path to the components folder (#{default_path}):" |> Mix.shell().prompt() |> String.trim() |> case do "" -> default_path path -> parse_path(path) end end defp prompt_color_scheme do prompt = "Select the color scheme to use (#{@default_color_scheme}):" response = prompt |> Mix.shell().prompt() |> String.trim() |> String.downcase() case response do "" -> Mix.shell().info("Using default color scheme: #{@default_color_scheme}") @default_color_scheme color_scheme when color_scheme in @color_schemes -> Mix.shell().info("Using color scheme: #{color_scheme}") color_scheme _ -> Mix.shell().error("Invalid color scheme") prompt_color_scheme() end end defp write_config(component_path) do write_dev_config(component_path) end defp write_dev_config(component_path) do Mix.shell().info("Writing components path to dev.exs") dev_config_path = Path.join(File.cwd!(), "config/dev.exs") components_config = [ salad_ui: %{ description: "Path to install SaladUI components", values: [components_path: "Path.join(File.cwd!(), \"#{component_path}\")"] } ] patch_config(dev_config_path, components_config) end defp patch_config(config_path, config) do if File.exists?(config_path) do Patcher.patch_config(config_path, config) :ok else {:error, "#{Path.basename(config_path)} not found"} end end defp init_tw_merge_cache(application_file_path) do cache_module = "TwMerge.Cache" description = "Start TwMerge cache" Mix.shell().info("Adding Tailwind merge cache to application supervisor") if File.exists?(application_file_path) do Patcher.patch_elixir_application( application_file_path, cache_module, description ) else {:error, "application.ex not found"} end end defp patch_css(color_scheme, assets_path) do app_css_path = Path.join(File.cwd!(), "assets/css/app.css") css_color_scheme_path = Path.join([assets_path, "colors", "#{color_scheme}.css"]) if File.exists?(app_css_path) do Mix.shell().info("Patching app.css") Patcher.patch_css_file(app_css_path, css_color_scheme_path) :ok else {:error, "app.css not found"} end end defp patch_js(assets_path) do app_js_path = Path.join(File.cwd!(), "assets/js/app.js") js_file_path = Path.join(assets_path, "server-events.js") if File.exists?(app_js_path) do Mix.shell().info("Patching app.js") Patcher.patch_js_file(app_js_path, js_file_path) :ok else {:error, "app.js not found"} end end defp copy_tailwind_colors(assets_path) do Mix.shell().info("Copying tailwind.colors.json to assets folder") source_path = Path.join(assets_path, "tailwind.colors.json") target_path = Path.join(File.cwd!(), "assets/tailwind.colors.json") unless File.exists?(target_path) do File.cp!(source_path, target_path) end :ok end defp patch_tailwind_config(opts) do Mix.shell().info("Patching tailwind.config.js") tailwind_config_path = Path.join(File.cwd!(), "assets/tailwind.config.js") if File.exists?(tailwind_config_path) do Patcher.patch_tailwind_config(tailwind_config_path, opts) :ok else {:error, "tailwind.config.js not found"} end end defp maybe_write_helpers_module(_component_path, _app_name, as_lib: true), do: :ok defp maybe_write_helpers_module(component_path, app_name, _opts) do Mix.shell().info("Writing helpers module") source_path = Path.join(get_base_path(), "helpers.ex") target_path = Path.join(component_path, "helpers.ex") module_name = Macro.camelize(app_name) source_code = Regex.replace( ~r/defmodule SaladUI\.Helpers/, File.read!(source_path), "defmodule #{module_name}Web.ComponentHelpers" ) File.write!(target_path, source_code) end defp maybe_write_component_module(_component_path, _app_name, as_lib: true), do: :ok defp maybe_write_component_module(component_path, app_name, _opts) do Mix.shell().info("Writing component module") source_path = Path.join(:code.priv_dir(:salad_ui), "templates/component.eex") target_path = Path.join(component_path, "component.ex") module_name = Macro.camelize(app_name) source_code = EEx.eval_file(source_path, module_name: module_name, assigns: %{module_name: module_name}) File.write!(target_path, source_code) end defp install_tailwind_animate(opts) do tag = Keyword.get(opts, :tailwind_animate_version, @default_tailwind_animate_version) Mix.shell().info("Downloading tailwindcss-animate.js v#{tag}") url = "https://raw.githubusercontent.com/jamiebuilds/tailwindcss-animate/refs/tags/v#{tag}/index.js" output_path = Keyword.get(opts, :output_path, Path.join(File.cwd!(), "assets/vendor/tailwindcss-animate.js")) :inets.start() :ssl.start() case :httpc.request(:get, {url, []}, [], body_format: :binary) do {:ok, {{_version, 200, _reason_phrase}, _headers, body}} -> # Write the body to file File.write!(output_path, body) :ok {:ok, {{_version, status_code, _reason_phrase}, _headers, _body}} -> {:error, "Failed to download tailwindcss-animate with status #{status_code}"} {:error, reason} -> {:error, "Failed to download tailwindcss-animate.js: #{inspect(reason)}"} end end defp print_usage, do: Mix.shell().info(@moduledoc) defp parse_path(path) do path # remove trailing slash |> String.replace(~r/\/$/, "") # remove leading slash |> String.replace(~r/^\.*\/?/, "") end defp build_assets_path(env) do ["_build", env, "lib/salad_ui/priv/static/assets"] |> Path.join() |> Path.expand() end defp build_default_component_path do app_name = Mix.Project.config()[:app] |> Atom.to_string() |> String.downcase() String.replace(@default_components_path, "%APP_NAME%", app_name) end end