├── .formatter.exs ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ ├── tailwind.ex │ │ └── tailwind.install.ex └── tailwind.ex ├── mix.exs ├── mix.lock └── test ├── tailwind_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | runs-on: ubuntu-24.04 10 | env: 11 | MIX_ENV: test 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: "1.14" 18 | otp: "25" 19 | - pair: 20 | elixir: "1.18" 21 | otp: "27" 22 | lint: lint 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - uses: erlef/setup-beam@v1 27 | with: 28 | otp-version: ${{matrix.pair.otp}} 29 | elixir-version: ${{matrix.pair.elixir}} 30 | 31 | - uses: actions/cache@v4 32 | with: 33 | path: deps 34 | key: mix-deps-${{ hashFiles('**/mix.lock') }} 35 | 36 | - run: mix deps.get 37 | 38 | - run: mix format --check-formatted 39 | if: ${{ matrix.lint }} 40 | 41 | - run: mix deps.unlock --check-unused 42 | if: ${{ matrix.lint }} 43 | 44 | - run: mix deps.compile 45 | 46 | - run: mix compile --warnings-as-errors 47 | if: ${{ matrix.lint }} 48 | 49 | - run: mix test 50 | if: ${{ ! matrix.lint }} 51 | 52 | - run: mix test --warnings-as-errors 53 | if: ${{ matrix.lint }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | tailwind-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | /assets/ 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.3.1 (2025-02-28) 4 | 5 | * Support correct target for Linux MUSL with Tailwind v3. 6 | 7 | ## v0.3.0 (2025-02-26) 8 | 9 | * Support Tailwind v4+. This release assumes Tailwind v4 for new projects. 10 | 11 | Note: v0.3.0 dropped target code for handling Linux MUSL with Tailwind v3. Use v0.3.1+ instead. 12 | 13 | ## v0.2.4 (2024-10-18) 14 | 15 | * Add version check flag 16 | * Fallback to ipv4/ipv6 for unreachable hosts 17 | 18 | ## v0.2.3 (2024-06-06) 19 | 20 | * Fix Elixir v1.15 deprecation warnings 21 | 22 | ## v0.2.2 (2023-11-03) 23 | 24 | * Bump tailwind to 3.4.6 25 | * Do not check version if path explicitly configured 26 | 27 | ## v0.2.1 (2023-06-26) 28 | 29 | * Support Elixir v1.15+ by ensuring inets and ssl are available even on `runtime: false` 30 | 31 | ## v0.2.0 (2023-03-16) 32 | 33 | * Require Elixir v1.11+ 34 | 35 | ## v0.1.10 (2023-02-09) 36 | 37 | * Declare inets and ssl for latest elixir support 38 | * Add FreeBSD targets 39 | * Add armv7 targets 40 | * Support custom URLs for fetching prebuilt tailwind binaries 41 | 42 | ## v0.1.9 (2022-09-06) 43 | 44 | * Use only TLS 1.2 on OTP versions less than 25. 45 | 46 | ## v0.1.8 (2022-07-14) 47 | 48 | * Fix generated tailwind.config.js missing plugin reference 49 | 50 | ## v0.1.7 (2022-07-13) 51 | 52 | * Bump tailwindcss to v3.1.6 53 | * Add Phoenix LiveView variants 54 | 55 | ## v0.1.6 (2022-06-09) 56 | 57 | * Bump tailwindcss to v3.1.0 58 | 59 | ## v0.1.5 (2022-01-18) 60 | 61 | * Prune app.js css import to remove required manual step on first install 62 | 63 | ## v0.1.4 (2022-01-07) 64 | 65 | * Bump tailwindcss to v3.0.12 to support alpine linux and others requiring statically linked builds 66 | 67 | ## v0.1.3 (2022-01-04) 68 | 69 | * Bump tailwindcss to v3.0.10 70 | * Inject tailwind imports into app.css on install 71 | * Prune phoenix.css import from app.css on install 72 | 73 | ## v0.1.2 (2021-12-21) 74 | 75 | * Fix tailwind v3 warnings and simplify generated `assets/tailwind.config.js` configuration 76 | 77 | ## v0.1.1 (2021-12-20) 78 | 79 | * First release 80 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 Wojtek Mach, José Valim. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailwind 2 | 3 | [![CI](https://github.com/phoenixframework/tailwind/actions/workflows/main.yml/badge.svg)](https://github.com/phoenixframework/tailwind/actions/workflows/main.yml) 4 | 5 | Mix tasks for installing and invoking [tailwindcss](https://tailwindcss.com) via the 6 | stand-alone [tailwindcss cli](https://github.com/tailwindlabs/tailwindcss/tree/master/standalone-cli) 7 | 8 | _Note_: The stand-alone Tailwind client bundles first-class Tailwind packages 9 | within the precompiled executable. For third-party Tailwind plugin support (e.g. DaisyUI), 10 | the node package must be used. See the [Tailwind Node.js installation instructions](https://tailwindcss.com/docs/installation) 11 | if you require third-party plugin support. 12 | 13 | ## Installation 14 | 15 | If you are going to build assets in production, then you add 16 | `tailwind` as dependency on all environments but only start it 17 | in dev: 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:tailwind, "~> 0.3", runtime: Mix.env() == :dev} 23 | ] 24 | end 25 | ``` 26 | 27 | However, if your assets are precompiled during development, 28 | then it only needs to be a dev dependency: 29 | 30 | ```elixir 31 | def deps do 32 | [ 33 | {:tailwind, "~> 0.3", only: :dev} 34 | ] 35 | end 36 | ``` 37 | 38 | Once installed, change your `config/config.exs` to pick your 39 | Tailwind version of choice: 40 | 41 | ```elixir 42 | config :tailwind, version: "4.0.9" 43 | ``` 44 | 45 | Note that `:tailwind` 0.3+ assumes Tailwind v4+ by default. It still supports Tailwind v3, but some configuration options when setting up a new 46 | project might be different. If you use Tailwind v3, also have a look at [the README in the 0.2 branch](https://github.com/phoenixframework/tailwind/blob/v0.2/README.md). 47 | 48 | Now you can install Tailwind by running: 49 | 50 | ```bash 51 | $ mix tailwind.install 52 | ``` 53 | 54 | or if your platform isn't officially supported by Tailwind, 55 | you can supply a third party path to the binary the installer wants 56 | (beware that we cannot guarantee the compatibility of any third party executable): 57 | 58 | ```bash 59 | $ mix tailwind.install https://people.freebsd.org/~dch/pub/tailwind/v3.2.6/tailwindcss-freebsd-x64 60 | ``` 61 | 62 | And invoke Tailwind with: 63 | 64 | ```bash 65 | $ mix tailwind default 66 | ``` 67 | 68 | The executable is kept at `_build/tailwind-TARGET`. 69 | Where `TARGET` is your system target architecture. 70 | 71 | ## Profiles 72 | 73 | The first argument to `tailwind` is the execution profile. 74 | You can define multiple execution profiles with the current 75 | directory, the OS environment, and default arguments to the 76 | `tailwind` task: 77 | 78 | ```elixir 79 | config :tailwind, 80 | version: "4.0.9", 81 | default: [ 82 | args: ~w( 83 | --input=assets/css/app.css 84 | --output=priv/static/assets/app.css 85 | ), 86 | cd: Path.expand("..", __DIR__) 87 | ] 88 | ``` 89 | 90 | When `mix tailwind default` is invoked, the task arguments will be appended 91 | to the ones configured above. Note profiles must be configured in your 92 | `config/config.exs`, as `tailwind` runs without starting your application 93 | (and therefore it won't pick settings in `config/runtime.exs`). 94 | 95 | ## Adding to Phoenix 96 | 97 | Note that applications generated with Phoenix older than 1.8 still use Tailwind v3 by default. 98 | If you're using Tailwind v3 please refer to [the README in the v0.2 branch](https://github.com/phoenixframework/tailwind/blob/v0.2/README.md#adding-to-phoenix) 99 | instead. 100 | 101 | To add Tailwind v4 to an application using Phoenix, first add this package 102 | as a dependency in your `mix.exs`: 103 | 104 | ```elixir 105 | def deps do 106 | [ 107 | {:phoenix, "~> 1.7"}, 108 | {:tailwind, "~> 0.3", runtime: Mix.env() == :dev} 109 | ] 110 | end 111 | ``` 112 | 113 | Also, in `mix.exs`, add `tailwind` to the `assets.deploy` 114 | alias for deployments (with the `--minify` option): 115 | 116 | ```elixir 117 | "assets.deploy": ["tailwind default --minify", ..., "phx.digest"] 118 | ``` 119 | 120 | Now let's change `config/config.exs` to tell `tailwind` 121 | to build our css bundle into `priv/static/assets`. 122 | We'll also give it our `assets/css/app.css` as our css entry point: 123 | 124 | ```elixir 125 | config :tailwind, 126 | version: "4.0.9", 127 | default: [ 128 | args: ~w( 129 | --input=assets/css/app.css 130 | --output=priv/static/assets/app.css 131 | ), 132 | cd: Path.expand("..", __DIR__) 133 | ] 134 | ``` 135 | 136 | > Make sure the "assets" directory from priv/static is listed in the 137 | > :only option for Plug.Static in your lib/my_app_web/endpoint.ex 138 | 139 | If your Phoenix application is using an umbrella structure, you should specify 140 | the web application's asset directory in the configuration: 141 | 142 | ```elixir 143 | config :tailwind, 144 | version: "4.0.9", 145 | default: [ 146 | args: ..., 147 | cd: Path.expand("../apps/", __DIR__) 148 | ] 149 | ``` 150 | 151 | For development, we want to enable watch mode. So find the `watchers` 152 | configuration in your `config/dev.exs` and add: 153 | 154 | ```elixir 155 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 156 | ``` 157 | 158 | Note we are enabling the file system watcher. 159 | 160 | Finally, run the command: 161 | 162 | ```bash 163 | $ mix tailwind.install 164 | ``` 165 | 166 | This command installs Tailwind and updates your `assets/css/app.css` 167 | and `assets/js/app.js` with the necessary changes to start using Tailwind 168 | right away. See `mix help tailwind.install` to learn more. 169 | 170 | ## Updating from Tailwind v3 to v4 171 | 172 | For a typical Phoenix application, updating from Tailwind v3 to v4 requires the following steps: 173 | 174 | 1. Update the `:tailwind` library to version 0.3+ 175 | 176 | ```diff 177 | defp deps do 178 | [ 179 | - {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 180 | + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, 181 | ] 182 | end 183 | ``` 184 | 185 | 2. Adjust the configuration to run Tailwind from the root of your repo (or the web app in an umbrella project): 186 | 187 | ```diff 188 | config :tailwind, 189 | - version: "3.4.13", 190 | + version: "4.0.9", 191 | default: [ 192 | args: ~w( 193 | - --config=tailwind.config.js 194 | - --input=css/app.css 195 | - --output=../priv/static/assets/app.css 196 | + --input=assets/css/app.css 197 | + --output=priv/static/assets/app.css 198 | ), 199 | - cd: Path.expand("../assets", __DIR__) 200 | + cd: Path.expand("..", __DIR__) 201 | ] 202 | ``` 203 | 204 | This allows Tailwind to automatically pick up classes from your project. Tailwind v4 does not require explicit configuration of sources. 205 | 206 | 3. Adjust the Tailwind imports in your app.css 207 | 208 | ```diff 209 | -@import "tailwindcss/base"; 210 | -@import "tailwindcss/components"; 211 | -@import "tailwindcss/utilities"; 212 | +@import "tailwindcss"; 213 | ``` 214 | 215 | 4. Follow the [Tailwind v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide) to address deprecations. 216 | 217 | 5. Optional: remove the `tailwind.config.js` and switch to the new CSS based configuration. For more information, see the previously mentioned upgrade guide and the [documentation on functions and directives](https://tailwindcss.com/docs/functions-and-directives). 218 | 219 | ## License 220 | 221 | Copyright (c) 2022 Chris McCord. 222 | Copyright (c) 2021 Wojtek Mach, José Valim. 223 | 224 | tailwind source code is licensed under the [MIT License](LICENSE.md). 225 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :tailwind, 4 | version: "4.0.9", 5 | another: [ 6 | args: ["--help"] 7 | ] 8 | -------------------------------------------------------------------------------- /lib/mix/tasks/tailwind.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Tailwind do 2 | @moduledoc """ 3 | Invokes tailwind with the given args. 4 | 5 | Usage: 6 | 7 | $ mix tailwind TASK_OPTIONS PROFILE TAILWIND_ARGS 8 | 9 | Example: 10 | 11 | $ mix tailwind default --minify --input=css/app.css \ 12 | --output=../priv/static/assets/app.css \ 13 | 14 | If Tailwind is not installed, it is automatically downloaded. 15 | Note the arguments given to this task will be appended 16 | to any configured arguments. 17 | 18 | If using Tailwind v3, you'd also pass the `--config=tailwind.config.js` 19 | flag pointing to your Tailwind configuration. In Tailwind v4, the 20 | configuration happens with the input CSS file. 21 | 22 | ## Options 23 | 24 | * `--runtime-config` - load the runtime configuration 25 | before executing command 26 | 27 | Note flags to control this Mix task must be given before the 28 | profile: 29 | 30 | $ mix tailwind --runtime-config default 31 | """ 32 | 33 | @shortdoc "Invokes tailwind with the profile and args" 34 | @compile {:no_warn_undefined, Mix} 35 | 36 | use Mix.Task 37 | 38 | @impl true 39 | def run(args) do 40 | switches = [runtime_config: :boolean] 41 | {opts, remaining_args} = OptionParser.parse_head!(args, switches: switches) 42 | 43 | if function_exported?(Mix, :ensure_application!, 1) do 44 | Mix.ensure_application!(:inets) 45 | Mix.ensure_application!(:ssl) 46 | end 47 | 48 | if opts[:runtime_config] do 49 | Mix.Task.run("app.config") 50 | else 51 | Mix.Task.run("loadpaths") 52 | Application.ensure_all_started(:tailwind) 53 | end 54 | 55 | Mix.Task.reenable("tailwind") 56 | install_and_run(remaining_args) 57 | end 58 | 59 | defp install_and_run([profile | args] = all) do 60 | case Tailwind.install_and_run(String.to_atom(profile), args) do 61 | 0 -> :ok 62 | status -> Mix.raise("`mix tailwind #{Enum.join(all, " ")}` exited with #{status}") 63 | end 64 | end 65 | 66 | defp install_and_run([]) do 67 | Mix.raise("`mix tailwind` expects the profile as argument") 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mix/tasks/tailwind.install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Tailwind.Install do 2 | @moduledoc """ 3 | Installs Tailwind executable and assets. 4 | 5 | $ mix tailwind.install 6 | $ mix tailwind.install --if-missing 7 | 8 | By default, it installs #{Tailwind.latest_version()} but you 9 | can configure it in your config files, such as: 10 | 11 | config :tailwind, :version, "#{Tailwind.latest_version()}" 12 | 13 | To install the Tailwind binary from a custom URL (e.g. if your platform isn't 14 | officially supported by Tailwind), you can supply a third party path to the 15 | binary (beware that we cannot guarantee the compatibility of any third party 16 | executable): 17 | 18 | ```bash 19 | $ mix tailwind.install https://people.freebsd.org/~dch/pub/tailwind/v3.2.6/tailwindcss-freebsd-x64 20 | ``` 21 | 22 | ## Options 23 | 24 | * `--runtime-config` - load the runtime configuration 25 | before executing command 26 | 27 | * `--if-missing` - install only if the given version 28 | does not exist 29 | 30 | * `--no-assets` - does not install Tailwind assets 31 | 32 | ## Assets 33 | 34 | Whenever Tailwind is installed, a default tailwind configuration 35 | will be placed in a new `assets/tailwind.config.js` file. See 36 | the [tailwind documentation](https://tailwindcss.com/docs/configuration) 37 | on configuration options. 38 | 39 | The default tailwind configuration includes Tailwind variants for Phoenix 40 | LiveView specific lifecycle classes: 41 | 42 | * phx-no-feedback - applied when feedback should be hidden from the user 43 | * phx-click-loading - applied when an event is sent to the server on click 44 | while the client awaits the server response 45 | * phx-submit-loading - applied when a form is submitted while the client awaits the server response 46 | * phx-submit-loading - applied when a form input is changed while the client awaits the server response 47 | 48 | Therefore, you may apply a variant, such as `phx-click-loading:animate-pulse` 49 | to customize tailwind classes when Phoenix LiveView classes are applied. 50 | """ 51 | 52 | @shortdoc "Installs Tailwind executable and assets" 53 | @compile {:no_warn_undefined, Mix} 54 | 55 | use Mix.Task 56 | 57 | @impl true 58 | def run(args) do 59 | valid_options = [runtime_config: :boolean, if_missing: :boolean, assets: :boolean] 60 | 61 | {opts, base_url} = 62 | case OptionParser.parse_head!(args, strict: valid_options) do 63 | {opts, []} -> 64 | {opts, Tailwind.default_base_url()} 65 | 66 | {opts, [base_url]} -> 67 | {opts, base_url} 68 | 69 | {_, _} -> 70 | Mix.raise(""" 71 | Invalid arguments to tailwind.install, expected one of: 72 | 73 | mix tailwind.install 74 | mix tailwind.install 'https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target' 75 | mix tailwind.install --runtime-config 76 | mix tailwind.install --if-missing 77 | """) 78 | end 79 | 80 | if opts[:runtime_config], do: Mix.Task.run("app.config") 81 | 82 | if opts[:if_missing] && latest_version?() do 83 | :ok 84 | else 85 | if Keyword.get(opts, :assets, true) do 86 | File.mkdir_p!("assets/css") 87 | 88 | prepare_app_css() 89 | prepare_app_js() 90 | end 91 | 92 | if function_exported?(Mix, :ensure_application!, 1) do 93 | Mix.ensure_application!(:inets) 94 | Mix.ensure_application!(:ssl) 95 | end 96 | 97 | Mix.Task.run("loadpaths") 98 | Tailwind.install(base_url) 99 | end 100 | end 101 | 102 | defp latest_version?() do 103 | version = Tailwind.configured_version() 104 | match?({:ok, ^version}, Tailwind.bin_version()) 105 | end 106 | 107 | defp prepare_app_css do 108 | app_css = 109 | case File.read("assets/css/app.css") do 110 | {:ok, str} -> str 111 | {:error, _} -> "" 112 | end 113 | 114 | unless app_css =~ "tailwind" do 115 | File.write!("assets/css/app.css", """ 116 | @import "tailwindcss"; 117 | @plugin "@tailwindcss/forms"; 118 | @variant phx-click-loading ([".phx-click-loading&", ".phx-click-loading &"]); 119 | @variant phx-submit-loading ([".phx-submit-loading&", ".phx-submit-loading &"]); 120 | @variant phx-change-loading ([".phx-change-loading&", ".phx-change-loading &"]); 121 | 122 | #{String.replace(app_css, ~s|@import "./phoenix.css";\n|, "")}\ 123 | """) 124 | end 125 | end 126 | 127 | defp prepare_app_js do 128 | case File.read("assets/js/app.js") do 129 | {:ok, app_js} -> 130 | File.write!("assets/js/app.js", String.replace(app_js, ~s|import "../css/app.css"\n|, "")) 131 | 132 | {:error, _} -> 133 | :ok 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/tailwind.ex: -------------------------------------------------------------------------------- 1 | defmodule Tailwind do 2 | # https://github.com/tailwindlabs/tailwindcss/releases 3 | @latest_version "4.0.9" 4 | 5 | @moduledoc """ 6 | Tailwind is an installer and runner for [tailwind](https://tailwindcss.com/). 7 | 8 | ## Profiles 9 | 10 | You can define multiple tailwind profiles. By default, there is a 11 | profile called `:default` which you can configure its args, current 12 | directory and environment: 13 | 14 | config :tailwind, 15 | version: "#{@latest_version}", 16 | default: [ 17 | args: ~w( 18 | --input=css/app.css 19 | --output=../priv/static/assets/app.css 20 | ), 21 | cd: Path.expand("../assets", __DIR__), 22 | ] 23 | 24 | ## Tailwind configuration 25 | 26 | There are four global configurations for the tailwind application: 27 | 28 | * `:version` - the expected tailwind version 29 | 30 | * `:version_check` - whether to perform the version check or not. 31 | Useful when you manage the tailwind executable with an external 32 | tool (eg. npm) 33 | 34 | * `:path` - the path to find the tailwind executable at. By 35 | default, it is automatically downloaded and placed inside 36 | the `_build` directory of your current app 37 | 38 | * `:target` - the target architecture for the tailwind executable. 39 | For example `"linux-x64-musl"`. By default, it is automatically detected 40 | based on system information. 41 | 42 | Overriding the `:path` is not recommended, as we will automatically 43 | download and manage `tailwind` for you. But in case you can't download 44 | it (for example, GitHub behind a proxy), you may want to 45 | set the `:path` to a configurable system location. 46 | 47 | For instance, you can install `tailwind` globally with `npm`: 48 | 49 | $ npm install -g tailwindcss 50 | 51 | On Unix, the executable will be at: 52 | 53 | NPM_ROOT/tailwind/node_modules/tailwind-TARGET/bin/tailwind 54 | 55 | On Windows, it will be at: 56 | 57 | NPM_ROOT/tailwind/node_modules/tailwind-windows-(32|64)/tailwind.exe 58 | 59 | Where `NPM_ROOT` is the result of `npm root -g` and `TARGET` is your system 60 | target architecture. 61 | 62 | Once you find the location of the executable, you can store it in a 63 | `MIX_TAILWIND_PATH` environment variable, which you can then read in 64 | your configuration file: 65 | 66 | config :tailwind, path: System.get_env("MIX_TAILWIND_PATH") 67 | 68 | """ 69 | 70 | use Application 71 | require Logger 72 | 73 | @doc false 74 | def start(_, _) do 75 | if Application.get_env(:tailwind, :version_check, true) do 76 | unless Application.get_env(:tailwind, :version) do 77 | Logger.warning(""" 78 | tailwind version is not configured. Please set it in your config files: 79 | 80 | config :tailwind, :version, "#{latest_version()}" 81 | """) 82 | end 83 | 84 | configured_version = configured_version() 85 | 86 | case bin_version() do 87 | {:ok, ^configured_version} -> 88 | :ok 89 | 90 | {:ok, version} -> 91 | Logger.warning(""" 92 | Outdated tailwind version. Expected #{configured_version}, got #{version}. \ 93 | Please run `mix tailwind.install` or update the version in your config files.\ 94 | """) 95 | 96 | :error -> 97 | :ok 98 | end 99 | end 100 | 101 | Supervisor.start_link([], strategy: :one_for_one) 102 | end 103 | 104 | @doc false 105 | # Latest known version at the time of publishing. 106 | def latest_version, do: @latest_version 107 | 108 | @doc """ 109 | Returns the configured tailwind version. 110 | """ 111 | def configured_version do 112 | Application.get_env(:tailwind, :version, latest_version()) 113 | end 114 | 115 | @doc """ 116 | Returns the configured tailwind target. By default, it is automatically detected. 117 | """ 118 | def configured_target do 119 | Application.get_env(:tailwind, :target, target()) 120 | end 121 | 122 | @doc """ 123 | Returns the configuration for the given profile. 124 | 125 | Returns nil if the profile does not exist. 126 | """ 127 | def config_for!(profile) when is_atom(profile) do 128 | Application.get_env(:tailwind, profile) || 129 | raise ArgumentError, """ 130 | unknown tailwind profile. Make sure the profile is defined in your config/config.exs file, such as: 131 | 132 | config :tailwind, 133 | version: "#{@latest_version}", 134 | #{profile}: [ 135 | args: ~w( 136 | --input=css/app.css 137 | --output=../priv/static/assets/app.css 138 | ), 139 | cd: Path.expand("../assets", __DIR__) 140 | ] 141 | """ 142 | end 143 | 144 | @doc """ 145 | Returns the path to the executable. 146 | 147 | The executable may not be available if it was not yet installed. 148 | """ 149 | def bin_path do 150 | name = "tailwind-#{configured_target()}" 151 | 152 | Application.get_env(:tailwind, :path) || 153 | if Code.ensure_loaded?(Mix.Project) do 154 | Path.join(Path.dirname(Mix.Project.build_path()), name) 155 | else 156 | Path.expand("_build/#{name}") 157 | end 158 | end 159 | 160 | @doc """ 161 | Returns the version of the tailwind executable. 162 | 163 | Returns `{:ok, version_string}` on success or `:error` when the executable 164 | is not available. 165 | """ 166 | def bin_version do 167 | path = bin_path() 168 | 169 | with true <- File.exists?(path), 170 | {out, 0} <- System.cmd(path, ["--help"]), 171 | [vsn] <- Regex.run(~r/tailwindcss v([^\s]+)/, out, capture: :all_but_first) do 172 | {:ok, vsn} 173 | else 174 | _ -> :error 175 | end 176 | end 177 | 178 | @doc """ 179 | Runs the given command with `args`. 180 | 181 | The given args will be appended to the configured args. 182 | The task output will be streamed directly to stdio. It 183 | returns the status of the underlying call. 184 | """ 185 | def run(profile, extra_args) when is_atom(profile) and is_list(extra_args) do 186 | config = config_for!(profile) 187 | args = config[:args] || [] 188 | 189 | env = 190 | config 191 | |> Keyword.get(:env, %{}) 192 | |> add_env_variable_to_ignore_browserslist_outdated_warning() 193 | 194 | opts = [ 195 | cd: config[:cd] || File.cwd!(), 196 | env: env, 197 | into: IO.stream(:stdio, :line), 198 | stderr_to_stdout: true 199 | ] 200 | 201 | bin_path() 202 | |> System.cmd(args ++ extra_args, opts) 203 | |> elem(1) 204 | end 205 | 206 | defp add_env_variable_to_ignore_browserslist_outdated_warning(env) do 207 | Enum.into(env, %{"BROWSERSLIST_IGNORE_OLD_DATA" => "1"}) 208 | end 209 | 210 | @doc """ 211 | Installs, if not available, and then runs `tailwind`. 212 | 213 | Returns the same as `run/2`. 214 | """ 215 | def install_and_run(profile, args) do 216 | unless File.exists?(bin_path()) do 217 | install() 218 | end 219 | 220 | run(profile, args) 221 | end 222 | 223 | @doc """ 224 | The default URL to install Tailwind from. 225 | """ 226 | def default_base_url do 227 | "https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target" 228 | end 229 | 230 | @doc """ 231 | Installs tailwind with `configured_version/0`. 232 | """ 233 | def install(base_url \\ default_base_url()) do 234 | url = get_url(base_url) 235 | bin_path = bin_path() 236 | binary = fetch_body!(url) 237 | File.mkdir_p!(Path.dirname(bin_path)) 238 | 239 | # MacOS doesn't recompute code signing information if a binary 240 | # is overwritten with a new version, so we force creation of a new file 241 | if File.exists?(bin_path) do 242 | File.rm!(bin_path) 243 | end 244 | 245 | File.write!(bin_path, binary, [:binary]) 246 | File.chmod(bin_path, 0o755) 247 | end 248 | 249 | # Available targets: 250 | # tailwindcss-freebsd-arm64 251 | # tailwindcss-freebsd-x64 252 | # tailwindcss-linux-arm64 253 | # tailwindcss-linux-x64 254 | # tailwindcss-linux-armv7 255 | # tailwindcss-macos-arm64 256 | # tailwindcss-macos-x64 257 | # tailwindcss-windows-x64.exe 258 | defp target do 259 | arch_str = :erlang.system_info(:system_architecture) 260 | target_triple = arch_str |> List.to_string() |> String.split("-") 261 | 262 | {arch, abi} = 263 | case target_triple do 264 | [arch, _vendor, _system, abi] -> {arch, abi} 265 | [arch, _vendor, abi] -> {arch, abi} 266 | [arch | _] -> {arch, nil} 267 | end 268 | 269 | case {:os.type(), arch, abi, :erlang.system_info(:wordsize) * 8} do 270 | {{:win32, _}, _arch, _abi, 64} -> 271 | "windows-x64.exe" 272 | 273 | {{:unix, :darwin}, arch, _abi, 64} when arch in ~w(arm aarch64) -> 274 | "macos-arm64" 275 | 276 | {{:unix, :darwin}, "x86_64", _abi, 64} -> 277 | "macos-x64" 278 | 279 | {{:unix, :freebsd}, "aarch64", _abi, 64} -> 280 | "freebsd-arm64" 281 | 282 | {{:unix, :freebsd}, arch, _abi, 64} when arch in ~w(x86_64 amd64) -> 283 | "freebsd-x64" 284 | 285 | {{:unix, :linux}, "aarch64", abi, 64} -> 286 | "linux-arm64" <> maybe_add_abi_suffix(abi) 287 | 288 | {{:unix, :linux}, "arm", _abi, 32} -> 289 | "linux-armv7" 290 | 291 | {{:unix, :linux}, "armv7" <> _, _abi, 32} -> 292 | "linux-armv7" 293 | 294 | {{:unix, _osname}, arch, abi, 64} when arch in ~w(x86_64 amd64) -> 295 | "linux-x64" <> maybe_add_abi_suffix(abi) 296 | 297 | {_os, _arch, _abi, _wordsize} -> 298 | raise "tailwind is not available for architecture: #{arch_str}" 299 | end 300 | end 301 | 302 | defp maybe_add_abi_suffix("musl") do 303 | # Tailwind CLI v4+ added explicit musl versions for Linux as 304 | # tailwind-linux-x64-musl 305 | # tailwind-linux-arm64-musl 306 | if Version.match?(configured_version(), "~> 4.0") do 307 | "-musl" 308 | else 309 | "" 310 | end 311 | end 312 | 313 | defp maybe_add_abi_suffix(_), do: "" 314 | 315 | defp fetch_body!(url, retry \\ true) when is_binary(url) do 316 | scheme = URI.parse(url).scheme 317 | url = String.to_charlist(url) 318 | Logger.debug("Downloading tailwind from #{url}") 319 | 320 | {:ok, _} = Application.ensure_all_started(:inets) 321 | {:ok, _} = Application.ensure_all_started(:ssl) 322 | 323 | if proxy = proxy_for_scheme(scheme) do 324 | %{host: host, port: port} = URI.parse(proxy) 325 | Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}") 326 | set_option = if "https" == scheme, do: :https_proxy, else: :proxy 327 | :httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}]) 328 | end 329 | 330 | http_options = 331 | [ 332 | ssl: [ 333 | verify: :verify_peer, 334 | cacerts: :public_key.cacerts_get(), 335 | depth: 2, 336 | customize_hostname_check: [ 337 | match_fun: :public_key.pkix_verify_hostname_match_fun(:https) 338 | ], 339 | versions: protocol_versions() 340 | ] 341 | ] 342 | |> maybe_add_proxy_auth(scheme) 343 | 344 | options = [body_format: :binary] 345 | 346 | case {retry, :httpc.request(:get, {url, []}, http_options, options)} do 347 | {_, {:ok, {{_, 200, _}, _headers, body}}} -> 348 | body 349 | 350 | {_, {:ok, {{_, 404, _}, _headers, _body}}} -> 351 | raise """ 352 | The tailwind binary couldn't be found at: #{url} 353 | 354 | This could mean that you're trying to install a version that does not support the detected 355 | target architecture. For example, Tailwind v4+ dropped support for 32-bit ARM. 356 | 357 | You can see the available files for the configured version at: 358 | 359 | https://github.com/tailwindlabs/tailwindcss/releases/tag/v#{configured_version()} 360 | """ 361 | 362 | {true, {:error, {:failed_connect, [{:to_address, _}, {inet, _, reason}]}}} 363 | when inet in [:inet, :inet6] and 364 | reason in [:ehostunreach, :enetunreach, :eprotonosupport, :nxdomain] -> 365 | :httpc.set_options(ipfamily: fallback(inet)) 366 | fetch_body!(to_string(url), false) 367 | 368 | other -> 369 | raise """ 370 | Couldn't fetch #{url}: #{inspect(other)} 371 | 372 | This typically means we cannot reach the source or you are behind a proxy. 373 | You can try again later and, if that does not work, you might: 374 | 375 | 1. If behind a proxy, ensure your proxy is configured and that 376 | your certificates are set via OTP ca certfile overide via SSL configuration. 377 | 378 | 2. Manually download the executable from the URL above and 379 | place it inside "_build/tailwind-#{configured_target()}" 380 | 381 | 3. Install and use Tailwind from npmJS. See our module documentation 382 | to learn more: https://hexdocs.pm/tailwind 383 | """ 384 | end 385 | end 386 | 387 | defp fallback(:inet), do: :inet6 388 | defp fallback(:inet6), do: :inet 389 | 390 | defp proxy_for_scheme("http") do 391 | System.get_env("HTTP_PROXY") || System.get_env("http_proxy") 392 | end 393 | 394 | defp proxy_for_scheme("https") do 395 | System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") 396 | end 397 | 398 | defp maybe_add_proxy_auth(http_options, scheme) do 399 | case proxy_auth(scheme) do 400 | nil -> http_options 401 | auth -> [{:proxy_auth, auth} | http_options] 402 | end 403 | end 404 | 405 | defp proxy_auth(scheme) do 406 | with proxy when is_binary(proxy) <- proxy_for_scheme(scheme), 407 | %{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy), 408 | [username, password] <- String.split(userinfo, ":") do 409 | {String.to_charlist(username), String.to_charlist(password)} 410 | else 411 | _ -> nil 412 | end 413 | end 414 | 415 | defp protocol_versions do 416 | if otp_version() < 25, do: [:"tlsv1.2"], else: [:"tlsv1.2", :"tlsv1.3"] 417 | end 418 | 419 | defp otp_version do 420 | :erlang.system_info(:otp_release) |> List.to_integer() 421 | end 422 | 423 | defp get_url(base_url) do 424 | base_url 425 | |> String.replace("$version", configured_version()) 426 | |> String.replace("$target", configured_target()) 427 | end 428 | end 429 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Tailwind.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.1" 5 | @source_url "https://github.com/phoenixframework/tailwind" 6 | 7 | def project do 8 | [ 9 | app: :tailwind, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | deps: deps(), 13 | description: "Mix tasks for installing and invoking tailwind", 14 | package: [ 15 | links: %{ 16 | "GitHub" => @source_url, 17 | "tailwind" => "https://tailwindcss.com" 18 | }, 19 | licenses: ["MIT"] 20 | ], 21 | docs: [ 22 | main: "Tailwind", 23 | source_url: @source_url, 24 | source_ref: "v#{@version}", 25 | extras: ["CHANGELOG.md"] 26 | ], 27 | aliases: [test: ["tailwind.install --if-missing", "test"]] 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | extra_applications: [:logger, inets: :optional, ssl: :optional], 34 | mod: {Tailwind, []}, 35 | env: [default: []] 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:ex_doc, ">= 0.0.0", only: :docs} 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 3 | "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, 4 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/tailwind_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TailwindTest do 2 | use ExUnit.Case, async: true 3 | 4 | @version Tailwind.latest_version() 5 | 6 | setup do 7 | Application.put_env(:tailwind, :version, @version) 8 | File.mkdir_p!("assets/js") 9 | File.mkdir_p!("assets/css") 10 | File.rm("assets/css/app.css") 11 | :ok 12 | end 13 | 14 | test "run on default" do 15 | assert ExUnit.CaptureIO.capture_io(fn -> 16 | assert Tailwind.run(:default, ["--help"]) == 0 17 | end) =~ @version 18 | end 19 | 20 | test "run on profile" do 21 | assert ExUnit.CaptureIO.capture_io(fn -> 22 | assert Tailwind.run(:another, []) == 0 23 | end) =~ @version 24 | end 25 | 26 | test "run with pre-existing app.css" do 27 | assert ExUnit.CaptureIO.capture_io(fn -> 28 | assert Tailwind.run(:default, []) == 0 29 | end) =~ @version 30 | end 31 | 32 | test "updates on install" do 33 | Application.put_env(:tailwind, :version, "3.4.17") 34 | Mix.Task.rerun("tailwind.install", ["--if-missing"]) 35 | 36 | assert ExUnit.CaptureIO.capture_io(fn -> 37 | assert Tailwind.run(:default, ["--help"]) == 0 38 | end) =~ "3.4.17" 39 | 40 | Application.delete_env(:tailwind, :version) 41 | 42 | Mix.Task.rerun("tailwind.install", ["--if-missing"]) 43 | assert File.read!("assets/css/app.css") =~ "tailwind" 44 | 45 | assert ExUnit.CaptureIO.capture_io(fn -> 46 | assert Tailwind.run(:default, ["--help"]) == 0 47 | end) =~ @version 48 | end 49 | 50 | test "install on existing app.css and app.js" do 51 | File.write!("assets/css/app.css", """ 52 | @import "./phoenix.css"; 53 | body { 54 | } 55 | """) 56 | 57 | File.write!("assets/js/app.js", """ 58 | import "../css/app.css" 59 | 60 | let Hooks = {} 61 | """) 62 | 63 | Mix.Task.rerun("tailwind.install") 64 | 65 | expected_css = 66 | String.trim(""" 67 | @import "tailwindcss"; 68 | @plugin "@tailwindcss/forms"; 69 | @variant phx-click-loading ([".phx-click-loading&", ".phx-click-loading &"]); 70 | @variant phx-submit-loading ([".phx-submit-loading&", ".phx-submit-loading &"]); 71 | @variant phx-change-loading ([".phx-change-loading&", ".phx-change-loading &"]); 72 | 73 | body { 74 | } 75 | """) 76 | 77 | expected_js = 78 | String.trim(""" 79 | 80 | let Hooks = {} 81 | """) 82 | 83 | assert String.trim(File.read!("assets/css/app.css")) == expected_css 84 | assert String.trim(File.read!("assets/js/app.js")) == expected_js 85 | 86 | Mix.Task.rerun("tailwind.install") 87 | 88 | assert String.trim(File.read!("assets/js/app.js")) == expected_js 89 | end 90 | 91 | test "installs with custom URL" do 92 | assert :ok = 93 | Mix.Task.rerun("tailwind.install", [ 94 | "https://github.com/tailwindlabs/tailwindcss/releases/download/v$version/tailwindcss-$target" 95 | ]) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------