defmodule Tailwind do
# see https://github.com/tailwindlabs/tailwindcss/releases
@latest_version "3.0.7"
@moduledoc """
Tailwind is an installer and runner for [Tailwind CLI](https://tailwindcss.com/blog/standalone-cli).
Think [esbuild](https://github.com/phoenixframework/esbuild) but for Tailwind,
allowing you to scratch NodeJS from your toolchain entirely.
## Profiles
You can define multiple `tailwind` profiles,
each profile configures arguments, current directory and environment variables:
config :tailwind,
# Version is optional, latest version will automatically be used if not specified.
version: "#{@latest_version}",
default: [
args: ~w(-i input.css -o output.css),
cd: Path.expand("../assets", __DIR__),
env: %{"SOME_VAR" => "some value"}
]
## Tailwind configuration
There are two global configurations for the tailwind application:
* `:version` - the expected tailwind cli version
* `:path` - the path to find the tailwind cli 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, you're behind a proxy), you may want to
set the `:path` to a configurable system location.
"""
use Application
require Logger
@doc false
def start(_, _) do
Supervisor.start_link([], strategy: :one_for_one)
end
@doc """
Gets the latest version available for Tailwind CLI at the moment of publishing.
"""
def latest_version, do: @latest_version
@doc """
Installs, if not available, and then runs `tailwind`.
Returns the same as `run/2`.
"""
def install_and_run(profile, args) do
install()
run(profile, args)
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] || []
opts = [
cd: config[:cd] || File.cwd!(),
env: config[:env] || %{},
into: IO.stream(:stdio, :line),
stderr_to_stdout: true
]
bin_path()
|> System.cmd(args ++ extra_args, opts)
|> elem(1)
end
@doc """
Installs `tailwind` in your system,
if `:force` is passed the install is always done regardless if the binary already exists.
"""
def install(:force) do
bin_path = bin_path()
if File.exists?(bin_path) do
File.rm!(bin_path)
end
Logger.info("Reinstalling Tailwind CLI v#{configured_version()}")
install()
end
def install() do
version = configured_version()
bin_path = bin_path()
unless File.exists?(bin_path) do
url =
"https://github.com/tailwindlabs/tailwindcss/releases/download/v#{version}/tailwindcss-#{target()}"
binary = fetch_body!(url)
File.mkdir_p!(Path.dirname(bin_path))
File.write!(bin_path, binary, [:binary])
File.chmod(bin_path, 0o755)
end
end
# Get the configured version, defaults to the latest available version.
defp configured_version, do: Application.get_env(:tailwind, :version, latest_version())
# Gets the platform target (linux-64, macos-arm64, windows-x64.exe)
defp target do
case :os.type() do
{:win32, _} ->
"windows-#{:erlang.system_info(:wordsize) * 8}.exe"
{:unix, osname} ->
arch_str = :erlang.system_info(:system_architecture)
[arch | _] = arch_str |> List.to_string() |> String.split("-")
osname = case osname do
:darwin ->
:macos
osname ->
osname
end
case arch do
"amd64" -> "#{osname}-x64"
"x86_64" -> "#{osname}-x64"
"aarch64" when osname == :macos -> "#{osname}-arm64"
"arm" when osname == :macos -> "#{osname}-arm64"
_ -> raise "esbuild is not available for architecture: #{arch_str}"
end
end
end
# copied from https://github.com/phoenixframework/esbuild/blob/main/lib/esbuild.ex
defp fetch_body!(url) do
url = String.to_charlist(url)
Logger.debug("Downloading Tailwind CLI from #{url}")
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
if proxy = System.get_env("HTTP_PROXY") || System.get_env("http_proxy") do
Logger.debug("Using HTTP_PROXY: #{proxy}")
%{host: host, port: port} = URI.parse(proxy)
:httpc.set_options([{:proxy, {{String.to_charlist(host), port}, []}}])
end
if proxy = System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") do
Logger.debug("Using HTTPS_PROXY: #{proxy}")
%{host: host, port: port} = URI.parse(proxy)
:httpc.set_options([{:https_proxy, {{String.to_charlist(host), port}, []}}])
end
# https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets
cacertfile = CAStore.file_path() |> 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)
]
]
]
options = [body_format: :binary]
case :httpc.request(:get, {url, []}, http_options, options) do
{:ok, {{_, 200, _}, _headers, body}} ->
body
other ->
raise "couldn't fetch #{url}: #{inspect(other)}"
end
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 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,
#{profile}: [
args: ~w(build -i ./css/app.css -o=../priv/static/assets/app.css),
cd: Path.expand("../assets", __DIR__)
]
"""
end
end