379 lines
11 KiB
Elixir
379 lines
11 KiB
Elixir
defmodule Tailwind do
|
|
# https://github.com/tailwindlabs/tailwindcss/releases
|
|
@latest_version "3.4.6"
|
|
|
|
@moduledoc """
|
|
Tailwind is an installer and runner for [tailwind](https://tailwindcss.com/).
|
|
|
|
## Profiles
|
|
|
|
You can define multiple tailwind profiles. By default, there is a
|
|
profile called `:default` which you can configure its args, current
|
|
directory and environment:
|
|
|
|
config :tailwind,
|
|
version: "#{@latest_version}",
|
|
default: [
|
|
args: ~w(
|
|
--config=tailwind.config.js
|
|
--input=css/app.css
|
|
--output=../priv/static/assets/app.css
|
|
),
|
|
cd: Path.expand("../assets", __DIR__),
|
|
]
|
|
|
|
## Tailwind configuration
|
|
|
|
There are two global configurations for the tailwind application:
|
|
|
|
* `:version` - the expected tailwind version
|
|
|
|
* `:version_check` - whether to perform the version check or not.
|
|
Useful when you manage the tailwind executable with an external
|
|
tool (eg. npm)
|
|
|
|
* `:cacerts_path` - the directory to find certificates for
|
|
https connections
|
|
|
|
* `:path` - the path to find the tailwind executable at. By
|
|
default, it is automatically downloaded and placed inside
|
|
the `_build` directory of your current app
|
|
|
|
Overriding the `:path` is not recommended, as we will automatically
|
|
download and manage `tailwind` for you. But in case you can't download
|
|
it (for example, GitHub behind a proxy), you may want to
|
|
set the `:path` to a configurable system location.
|
|
|
|
For instance, you can install `tailwind` globally with `npm`:
|
|
|
|
$ npm install -g tailwindcss
|
|
|
|
On Unix, the executable will be at:
|
|
|
|
NPM_ROOT/tailwind/node_modules/tailwind-TARGET/bin/tailwind
|
|
|
|
On Windows, it will be at:
|
|
|
|
NPM_ROOT/tailwind/node_modules/tailwind-windows-(32|64)/tailwind.exe
|
|
|
|
Where `NPM_ROOT` is the result of `npm root -g` and `TARGET` is your system
|
|
target architecture.
|
|
|
|
Once you find the location of the executable, you can store it in a
|
|
`MIX_TAILWIND_PATH` environment variable, which you can then read in
|
|
your configuration file:
|
|
|
|
config :tailwind, path: System.get_env("MIX_TAILWIND_PATH")
|
|
|
|
"""
|
|
|
|
use Application
|
|
require Logger
|
|
|
|
@doc false
|
|
def start(_, _) do
|
|
if Application.get_env(:tailwind, :version_check, true) do
|
|
unless Application.get_env(:tailwind, :version) do
|
|
Logger.warning("""
|
|
tailwind version is not configured. Please set it in your config files:
|
|
|
|
config :tailwind, :version, "#{latest_version()}"
|
|
""")
|
|
end
|
|
|
|
configured_version = configured_version()
|
|
|
|
case bin_version() 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.\
|
|
""")
|
|
|
|
:error ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
Supervisor.start_link([], strategy: :one_for_one)
|
|
end
|
|
|
|
@doc false
|
|
# Latest known version at the time of publishing.
|
|
def latest_version, do: @latest_version
|
|
|
|
@doc """
|
|
Returns the configured tailwind version.
|
|
"""
|
|
def configured_version do
|
|
Application.get_env(:tailwind, :version, latest_version())
|
|
end
|
|
|
|
@doc """
|
|
Returns the configuration for the given profile.
|
|
|
|
Returns nil if the profile does not exist.
|
|
"""
|
|
def config_for!(profile) when is_atom(profile) do
|
|
Application.get_env(:tailwind, profile) ||
|
|
raise ArgumentError, """
|
|
unknown tailwind profile. Make sure the profile is defined in your config/config.exs file, such as:
|
|
|
|
config :tailwind,
|
|
version: "#{@latest_version}",
|
|
#{profile}: [
|
|
args: ~w(
|
|
--config=tailwind.config.js
|
|
--input=css/app.css
|
|
--output=../priv/static/assets/app.css
|
|
),
|
|
cd: Path.expand("../assets", __DIR__)
|
|
]
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Returns the path to the executable.
|
|
|
|
The executable may not be available if it was not yet installed.
|
|
"""
|
|
def bin_path do
|
|
name = "tailwind-#{target()}"
|
|
|
|
Application.get_env(:tailwind, :path) ||
|
|
if Code.ensure_loaded?(Mix.Project) do
|
|
Path.join(Path.dirname(Mix.Project.build_path()), name)
|
|
else
|
|
Path.expand("_build/#{name}")
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the version of the tailwind executable.
|
|
|
|
Returns `{:ok, version_string}` on success or `:error` when the executable
|
|
is not available.
|
|
"""
|
|
def bin_version do
|
|
path = bin_path()
|
|
|
|
with true <- File.exists?(path),
|
|
{out, 0} <- System.cmd(path, ["--help"]),
|
|
[vsn] <- Regex.run(~r/tailwindcss v([^\s]+)/, out, capture: :all_but_first) do
|
|
{:ok, vsn}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Runs the given command with `args`.
|
|
|
|
The given args will be appended to the configured args.
|
|
The task output will be streamed directly to stdio. It
|
|
returns the status of the underlying call.
|
|
"""
|
|
def run(profile, extra_args) when is_atom(profile) and is_list(extra_args) do
|
|
config = config_for!(profile)
|
|
args = config[:args] || []
|
|
|
|
env =
|
|
config
|
|
|> Keyword.get(:env, %{})
|
|
|> add_env_variable_to_ignore_browserslist_outdated_warning()
|
|
|
|
opts = [
|
|
cd: config[:cd] || File.cwd!(),
|
|
env: env,
|
|
into: IO.stream(:stdio, :line),
|
|
stderr_to_stdout: true
|
|
]
|
|
|
|
bin_path()
|
|
|> System.cmd(args ++ extra_args, opts)
|
|
|> elem(1)
|
|
end
|
|
|
|
defp add_env_variable_to_ignore_browserslist_outdated_warning(env) do
|
|
Enum.into(env, %{"BROWSERSLIST_IGNORE_OLD_DATA" => "1"})
|
|
end
|
|
|
|
@doc """
|
|
Installs, if not available, and then runs `tailwind`.
|
|
|
|
Returns the same as `run/2`.
|
|
"""
|
|
def install_and_run(profile, args) do
|
|
unless File.exists?(bin_path()) do
|
|
install()
|
|
end
|
|
|
|
run(profile, args)
|
|
end
|
|
|
|
@doc """
|
|
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`.
|
|
"""
|
|
def install(base_url \\ default_base_url()) do
|
|
url = get_url(base_url)
|
|
bin_path = bin_path()
|
|
binary = fetch_body!(url)
|
|
File.mkdir_p!(Path.dirname(bin_path))
|
|
|
|
# MacOS doesn't recompute code signing information if a binary
|
|
# is overwritten with a new version, so we force creation of a new file
|
|
if File.exists?(bin_path) do
|
|
File.rm!(bin_path)
|
|
end
|
|
|
|
File.write!(bin_path, binary, [:binary])
|
|
File.chmod(bin_path, 0o755)
|
|
end
|
|
|
|
# Available targets:
|
|
# tailwindcss-freebsd-arm64
|
|
# tailwindcss-freebsd-x64
|
|
# tailwindcss-linux-arm64
|
|
# tailwindcss-linux-x64
|
|
# tailwindcss-linux-armv7
|
|
# tailwindcss-macos-arm64
|
|
# tailwindcss-macos-x64
|
|
# tailwindcss-windows-x64.exe
|
|
defp target do
|
|
arch_str = :erlang.system_info(:system_architecture)
|
|
[arch | _] = arch_str |> List.to_string() |> String.split("-")
|
|
|
|
case {:os.type(), arch, :erlang.system_info(:wordsize) * 8} do
|
|
{{:win32, _}, _arch, 64} -> "windows-x64.exe"
|
|
{{:unix, :darwin}, arch, 64} when arch in ~w(arm aarch64) -> "macos-arm64"
|
|
{{:unix, :darwin}, "x86_64", 64} -> "macos-x64"
|
|
{{:unix, :freebsd}, "aarch64", 64} -> "freebsd-arm64"
|
|
{{:unix, :freebsd}, "amd64", 64} -> "freebsd-x64"
|
|
{{:unix, :linux}, "aarch64", 64} -> "linux-arm64"
|
|
{{:unix, :linux}, "arm", 32} -> "linux-armv7"
|
|
{{:unix, :linux}, "armv7" <> _, 32} -> "linux-armv7"
|
|
{{:unix, _osname}, arch, 64} when arch in ~w(x86_64 amd64) -> "linux-x64"
|
|
{_os, _arch, _wordsize} -> raise "tailwind is not available for architecture: #{arch_str}"
|
|
end
|
|
end
|
|
|
|
defp fetch_body!(url, retry \\ true) do
|
|
scheme = URI.parse(url).scheme
|
|
url = String.to_charlist(url)
|
|
Logger.debug("Downloading tailwind from #{url}")
|
|
|
|
{:ok, _} = Application.ensure_all_started(:inets)
|
|
{:ok, _} = Application.ensure_all_started(:ssl)
|
|
|
|
if proxy = proxy_for_scheme(scheme) do
|
|
%{host: host, port: port} = URI.parse(proxy)
|
|
Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}")
|
|
set_option = if "https" == scheme, do: :https_proxy, else: :proxy
|
|
:httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}])
|
|
end
|
|
|
|
# https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets
|
|
cacertfile = cacertfile() |> String.to_charlist()
|
|
|
|
http_options =
|
|
[
|
|
ssl: [
|
|
verify: :verify_peer,
|
|
cacertfile: cacertfile,
|
|
depth: 2,
|
|
customize_hostname_check: [
|
|
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
|
|
],
|
|
versions: protocol_versions()
|
|
]
|
|
]
|
|
|> maybe_add_proxy_auth(scheme)
|
|
|
|
options = [body_format: :binary]
|
|
|
|
case {retry, :httpc.request(:get, {url, []}, http_options, options)} do
|
|
{_, {:ok, {{_, 200, _}, _headers, body}}} ->
|
|
body
|
|
|
|
{true, {:error, {:failed_connect, [{:to_address, _}, {inet, _, reason}]}}}
|
|
when inet in [:inet, :inet6] and
|
|
reason in [:ehostunreach, :enetunreach, :eprotonosupport, :nxdomain] ->
|
|
:httpc.set_options(ipfamily: fallback(inet))
|
|
fetch_body!(url, false)
|
|
|
|
other ->
|
|
raise """
|
|
Couldn't fetch #{url}: #{inspect(other)}
|
|
|
|
This typically means we cannot reach the source or you are behind a proxy.
|
|
You can try again later and, if that does not work, you might:
|
|
|
|
1. If behind a proxy, ensure your proxy is configured and that
|
|
your certificates are set via the cacerts_path configuration
|
|
|
|
2. Manually download the executable from the URL above and
|
|
place it inside "_build/tailwind-#{target()}"
|
|
|
|
3. Install and use Tailwind from npmJS. See our module documentation
|
|
to learn more: https://hexdocs.pm/tailwind
|
|
"""
|
|
end
|
|
end
|
|
|
|
defp fallback(:inet), do: :inet6
|
|
defp fallback(:inet6), do: :inet
|
|
|
|
defp proxy_for_scheme("http") do
|
|
System.get_env("HTTP_PROXY") || System.get_env("http_proxy")
|
|
end
|
|
|
|
defp proxy_for_scheme("https") do
|
|
System.get_env("HTTPS_PROXY") || System.get_env("https_proxy")
|
|
end
|
|
|
|
defp maybe_add_proxy_auth(http_options, scheme) do
|
|
case proxy_auth(scheme) do
|
|
nil -> http_options
|
|
auth -> [{:proxy_auth, auth} | http_options]
|
|
end
|
|
end
|
|
|
|
defp proxy_auth(scheme) do
|
|
with proxy when is_binary(proxy) <- proxy_for_scheme(scheme),
|
|
%{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy),
|
|
[username, password] <- String.split(userinfo, ":") do
|
|
{String.to_charlist(username), String.to_charlist(password)}
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp cacertfile() do
|
|
Application.get_env(:tailwind, :cacerts_path) || CAStore.file_path()
|
|
end
|
|
|
|
defp protocol_versions do
|
|
if otp_version() < 25, do: [:"tlsv1.2"], else: [:"tlsv1.2", :"tlsv1.3"]
|
|
end
|
|
|
|
defp otp_version do
|
|
:erlang.system_info(:otp_release) |> List.to_integer()
|
|
end
|
|
|
|
defp get_url(base_url) do
|
|
base_url
|
|
|> String.replace("$version", configured_version())
|
|
|> String.replace("$target", target())
|
|
end
|
|
end
|