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