Skip to content
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