diff --git a/lib/mix/tasks/tailwind.install.ex b/lib/mix/tasks/tailwind.install.ex index 0e8fe0a..6489802 100644 --- a/lib/mix/tasks/tailwind.install.ex +++ b/lib/mix/tasks/tailwind.install.ex @@ -2,6 +2,12 @@ defmodule Mix.Tasks.Tailwind.Install do @moduledoc """ Installs Tailwind executable and assets. + Usage: + + $ mix tailwind.install TASK_OPTIONS BASE_URL + + Example: + $ mix tailwind.install $ mix tailwind.install --if-missing @@ -15,9 +21,7 @@ defmodule Mix.Tasks.Tailwind.Install do binary (beware that we cannot guarantee the compatibility of any third party executable): - ```bash - $ mix tailwind.install https://people.freebsd.org/~dch/pub/tailwind/v3.2.6/tailwindcss-freebsd-x64 - ``` + $ mix tailwind.install https://people.freebsd.org/~dch/pub/tailwind/$version/tailwindcss-$target ## Options @@ -56,52 +60,73 @@ defmodule Mix.Tasks.Tailwind.Install do @impl true def run(args) do - valid_options = [runtime_config: :boolean, if_missing: :boolean, assets: :boolean] + if args |> try_install() |> was_successful?() do + :ok + else + :error + end + end + + defp try_install(args) do + {opts, base_url} = parse_arguments(args) + + if opts[:runtime_config], do: Mix.Task.run("app.config") - {opts, base_url} = - case OptionParser.parse_head!(args, strict: valid_options) do - {opts, []} -> - {opts, Tailwind.default_base_url()} + for {version, latest?} <- collect_versions() do + if opts[:if_missing] && latest? do + :ok + else + if Keyword.get(opts, :assets, true) do + File.mkdir_p!("assets/css") - {opts, [base_url]} -> - {opts, base_url} + prepare_app_css() + prepare_app_js() + end - {_, _} -> - Mix.raise(""" - Invalid arguments to tailwind.install, expected one of: + if function_exported?(Mix, :ensure_application!, 1) do + Mix.ensure_application!(:inets) + Mix.ensure_application!(:ssl) + end - mix tailwind.install - mix tailwind.install 'https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target' - mix tailwind.install --runtime-config - mix tailwind.install --if-missing - """) + Mix.Task.run("loadpaths") + Tailwind.install(base_url, version) end + end + end - if opts[:runtime_config], do: Mix.Task.run("app.config") + defp parse_arguments(args) do + case OptionParser.parse_head!(args, strict: schema()) do + {opts, []} -> + {opts, Tailwind.default_base_url()} - if opts[:if_missing] && latest_version?() do - :ok - else - if Keyword.get(opts, :assets, true) do - File.mkdir_p!("assets/css") + {opts, [base_url]} -> + {opts, base_url} - prepare_app_css() - prepare_app_js() - end + {_, _} -> + Mix.raise(""" + Invalid arguments to tailwind.install, expected one of: - if function_exported?(Mix, :ensure_application!, 1) do - Mix.ensure_application!(:inets) - Mix.ensure_application!(:ssl) - end + mix tailwind.install + mix tailwind.install 'https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target' + mix tailwind.install --runtime-config + mix tailwind.install --if-missing + """) + end + end - Mix.Task.run("loadpaths") - Tailwind.install(base_url) + defp collect_versions do + for {profile, _} <- Tailwind.profiles(), uniq: true do + {Tailwind.configured_version(profile), latest_version?(profile)} end end - defp latest_version?() do - version = Tailwind.configured_version() - match?({:ok, ^version}, Tailwind.bin_version()) + defp was_successful?(results) do + Enum.all?(results, &(&1 == :ok)) + end + + defp latest_version?(profile) do + version = Tailwind.configured_version(profile) + match?({:ok, ^version}, Tailwind.bin_version(profile)) end defp prepare_app_css do @@ -133,4 +158,8 @@ defmodule Mix.Tasks.Tailwind.Install do :ok end end + + defp schema do + [runtime_config: :boolean, if_missing: :boolean, assets: :boolean] + end end diff --git a/lib/tailwind.ex b/lib/tailwind.ex index 63b3115..7086170 100644 --- a/lib/tailwind.ex +++ b/lib/tailwind.ex @@ -21,6 +21,9 @@ defmodule Tailwind do cd: Path.expand("../assets", __DIR__), ] + It is also possible to override the required tailwind CLI version on + profile-basis. + ## Tailwind configuration There are four global configurations for the tailwind application: @@ -81,20 +84,22 @@ defmodule Tailwind do """) end - configured_version = configured_version() + for {profile, config} <- profiles() do + configured_version = Keyword.get(config, :version, configured_version()) - case bin_version() do - {:ok, ^configured_version} -> - :ok + case bin_version(profile) do + {:ok, ^configured_version} -> + :ok - {:ok, version} -> - Logger.warning(""" - Outdated tailwind version. Expected #{configured_version}, got #{version}. \ - Please run `mix tailwind.install` or update the version in your config files.\ - """) + {:ok, version} -> + Logger.warning(""" + Outdated tailwind version. Expected #{configured_version}, got #{version}. \ + Please run `mix tailwind.install` or update the version in your config files.\ + """) - :error -> - :ok + :error -> + :ok + end end end @@ -105,6 +110,16 @@ defmodule Tailwind do # Latest known version at the time of publishing. def latest_version, do: @latest_version + @doc false + def profiles do + config_keys = [:version_check, :version, :target, :path] + + :tailwind + |> Application.get_all_env() + |> Keyword.drop(config_keys) + |> Enum.filter(&Keyword.keyword?(elem(&1, 1))) + end + @doc """ Returns the configured tailwind version. """ @@ -112,17 +127,29 @@ defmodule Tailwind do Application.get_env(:tailwind, :version, latest_version()) end + @doc """ + Returns the configured tailwind version for a specific profile. + + If not explicitly configured, falls back to `configured_version/0`. + Raises if the given profile does not exist. + """ + def configured_version(profile) when is_atom(profile) do + :tailwind + |> Application.get_env(profile, []) + |> Keyword.get(:version, configured_version()) + end + @doc """ Returns the configured tailwind target. By default, it is automatically detected. """ def configured_target do - Application.get_env(:tailwind, :target, target()) + Application.get_env(:tailwind, :target, system_target()) end @doc """ Returns the configuration for the given profile. - Returns nil if the profile does not exist. + Raises if the profile does not exist. """ def config_for!(profile) when is_atom(profile) do Application.get_env(:tailwind, profile) || @@ -142,12 +169,12 @@ defmodule Tailwind do end @doc """ - Returns the path to the executable. + Returns the path to the executable for the given `version`. The executable may not be available if it was not yet installed. """ - def bin_path do - name = "tailwind-#{configured_target()}" + def bin_path(version \\ configured_version()) do + name = "tailwind-#{configured_target()}-#{version}" Application.get_env(:tailwind, :path) || if Code.ensure_loaded?(Mix.Project) do @@ -158,14 +185,25 @@ defmodule Tailwind do end @doc """ - Returns the version of the tailwind executable. + Returns the version of the executable. - Returns `{:ok, version_string}` on success or `:error` when the executable + Returns `{:ok, vsn}` on success or `:error` when the executable is not available. """ def bin_version do - path = bin_path() + configured_version() + |> bin_path() + |> get_version() + end + def bin_version(profile) when is_atom(profile) do + profile + |> configured_version() + |> bin_path() + |> get_version() + end + + defp get_version(path) do with true <- File.exists?(path), {out, 0} <- System.cmd(path, ["--help"]), [vsn] <- Regex.run(~r/tailwindcss v([^\s]+)/, out, capture: :all_but_first) do @@ -176,7 +214,7 @@ defmodule Tailwind do end @doc """ - Runs the given command with `args`. + Runs the tailwind CLI for the given `profile` with `args`. The given args will be appended to the configured args. The task output will be streamed directly to stdio. It @@ -198,7 +236,9 @@ defmodule Tailwind do stderr_to_stdout: true ] - bin_path() + profile + |> configured_version() + |> bin_path() |> System.cmd(args ++ extra_args, opts) |> elem(1) end @@ -208,20 +248,22 @@ defmodule Tailwind do end @doc """ - Installs, if not available, and then runs `tailwind`. + Installs, if not available, and then runs the tailwind CLI. Returns the same as `run/2`. """ - def install_and_run(profile, args) do - unless File.exists?(bin_path()) do - install() + def install_and_run(profile, args) when is_atom(profile) do + version = configured_version(profile) + + unless File.exists?(bin_path(version)) do + install(default_base_url(), version) end run(profile, args) end @doc """ - The default URL to install Tailwind from. + Returns the default URL to install Tailwind from. """ def default_base_url do "https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target" @@ -229,10 +271,18 @@ defmodule Tailwind do @doc """ Installs tailwind with `configured_version/0`. + + If given, the executable is downloaded from `base_url`, + otherwise, `default_base_url/0` is used. """ def install(base_url \\ default_base_url()) do - url = get_url(base_url) - bin_path = bin_path() + install(base_url, configured_version()) + end + + @doc false + def install(base_url, version) do + url = get_url(base_url, version) + bin_path = bin_path(version) binary = fetch_body!(url) File.mkdir_p!(Path.dirname(bin_path)) @@ -255,7 +305,7 @@ defmodule Tailwind do # tailwindcss-macos-arm64 # tailwindcss-macos-x64 # tailwindcss-windows-x64.exe - defp target do + defp system_target do arch_str = :erlang.system_info(:system_architecture) target_triple = arch_str |> List.to_string() |> String.split("-") @@ -420,9 +470,9 @@ defmodule Tailwind do :erlang.system_info(:otp_release) |> List.to_integer() end - defp get_url(base_url) do + defp get_url(base_url, version) do base_url - |> String.replace("$version", configured_version()) + |> String.replace("$version", version) |> String.replace("$target", configured_target()) end end diff --git a/test/tailwind_test.exs b/test/tailwind_test.exs index f583bac..a7aeb24 100644 --- a/test/tailwind_test.exs +++ b/test/tailwind_test.exs @@ -39,7 +39,7 @@ defmodule TailwindTest do Application.delete_env(:tailwind, :version) - Mix.Task.rerun("tailwind.install", ["--if-missing"]) + Mix.Task.rerun("tailwind.install", []) assert File.read!("assets/css/app.css") =~ "tailwind" assert ExUnit.CaptureIO.capture_io(fn ->