Skip to content

Support configuring version per profile #123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
101 changes: 65 additions & 36 deletions lib/mix/tasks/tailwind.install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
112 changes: 81 additions & 31 deletions lib/tailwind.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -105,24 +110,46 @@ 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.
"""
def configured_version 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) ||
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -208,31 +248,41 @@ 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"
end

@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))

Expand All @@ -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("-")

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion test/tailwind_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down