diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c2ff4..f44ed5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Bug fixes + +- [CanonicalTailwind.Config] Pass a tailwind profile's `:env` to `tailwindcss canonicalize`. A CSS entrypoint can depend on environment that only the profile carries, such as `NODE_PATH`. ([#11](https://github.com/aptinio/canonical_tailwind/issues/11)) + +### Enhancements + +- [CanonicalTailwind.Config] Add `:env` for projects that configure `:binary` rather than a tailwind profile. + ## v0.3.3 (2026-09-30) ### Bug fixes diff --git a/README.md b/README.md index 3853e00..aa442af 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,10 @@ determining canonical forms. - **`:cd`** — working directory for the CLI process (defaults to the project root) - **`:input`** — CSS entrypoint, relative to `:cd` +- **`:env`** — environment for the CLI process, as a map or keyword + list; a list value is joined with the OS path separator (e.g. + `%{"NODE_PATH" => ["deps", "_build/dev"]}`). `NODE_PATH` requires + tailwindcss 4.2.3 or newer ```elixir # .formatter.exs diff --git a/lib/canonical_tailwind/canonicalizer/worker.ex b/lib/canonical_tailwind/canonicalizer/worker.ex index 592a535..7a058da 100644 --- a/lib/canonical_tailwind/canonicalizer/worker.ex +++ b/lib/canonical_tailwind/canonicalizer/worker.ex @@ -16,12 +16,20 @@ defmodule CanonicalTailwind.Canonicalizer.Worker do :use_stdio, {:line, 65_536}, {:cd, to_charlist(config.cd)}, + {:env, env_charlists(config.env)}, args: config.args ] Port.open({:spawn_executable, config.binary}, port_opts) end + defp env_charlists(env) do + Enum.map(env, fn + {name, nil} -> {to_charlist(name), false} + {name, value} -> {to_charlist(name), to_charlist(value)} + end) + end + @impl GenServer def handle_call({:canonicalize, class_string}, _from, state) do Port.command(state.port, [class_string, ?\n]) diff --git a/lib/canonical_tailwind/config.ex b/lib/canonical_tailwind/config.ex index 854d686..330f0de 100644 --- a/lib/canonical_tailwind/config.ex +++ b/lib/canonical_tailwind/config.ex @@ -7,7 +7,7 @@ defmodule CanonicalTailwind.Config do @minimum_version Version.parse!("4.2.2") @non_profile_keys [:version, :version_check, :path, :target, :cacerts_path] - @enforce_keys [:args, :binary, :cd, :timeout] + @enforce_keys [:args, :binary, :cd, :env, :timeout] defstruct @enforce_keys def resolve!(formatter_opts, tailwind_env) do @@ -17,6 +17,7 @@ defmodule CanonicalTailwind.Config do {binary, profile_config} = resolve_binary!(opts, tailwind_env) cd = resolve_cd!(opts, profile_config) validate_cd!(cd) + env = resolve_env(opts, profile_config) binary = Path.expand(binary, cd) validate_binary!(binary) ensure_minimum_version!(binary, opts) @@ -35,6 +36,7 @@ defmodule CanonicalTailwind.Config do args: args, binary: binary, cd: cd, + env: env, timeout: timeout } end @@ -209,6 +211,26 @@ defmodule CanonicalTailwind.Config do end end + defp resolve_env(opts, profile_config) do + env = + case Keyword.get(opts, :env) do + nil -> profile_config[:env] || %{} + env -> env + end + + Map.new(env, fn + {name, value} when is_list(value) -> {name, Enum.join(value, path_sep())} + other -> other + end) + end + + defp path_sep do + case :os.type() do + {:win32, _} -> ";" + _ -> ":" + end + end + defp validate_binary!(binary) do if !System.find_executable(binary) do raise ArgumentError, diff --git a/test/canonical_tailwind/canonicalizer/worker_test.exs b/test/canonical_tailwind/canonicalizer/worker_test.exs index 5de1cb0..c70de8e 100644 --- a/test/canonical_tailwind/canonicalizer/worker_test.exs +++ b/test/canonical_tailwind/canonicalizer/worker_test.exs @@ -48,6 +48,7 @@ defmodule CanonicalTailwind.Canonicalizer.WorkerTest do args: ["canonicalize", "--stream"], binary: Path.expand("../../fixtures/#{fixture}", __DIR__), cd: File.cwd!(), + env: %{}, timeout: Keyword.fetch!(opts, :timeout) } diff --git a/test/canonical_tailwind/canonicalizer_test.exs b/test/canonical_tailwind/canonicalizer_test.exs index 0b6b1cb..2f7ead9 100644 --- a/test/canonical_tailwind/canonicalizer_test.exs +++ b/test/canonical_tailwind/canonicalizer_test.exs @@ -97,6 +97,48 @@ defmodule CanonicalTailwind.CanonicalizerTest do "p-4 min-[40rem]:flex" end + test "runs the CLI with the configured env" do + binary = Path.expand("../fixtures/tailwindcss-echoes-node-path", __DIR__) + + opts = [ + canonical_tailwind: [ + binary: binary, + cd: File.cwd!(), + env: %{"NODE_PATH" => ["deps", "_build/dev"]} + ] + ] + + # The fixture CLI ignores the classes and answers with its own NODE_PATH, so + # the response is the joined value as the spawned process received it. + assert Canonicalizer.canonicalize("p-0 flex", opts) == "deps:_build/dev" + end + + test "unsets an inherited env variable in the CLI without changing the parent" do + node_path = System.get_env("NODE_PATH") + + on_exit(fn -> + if node_path do + System.put_env("NODE_PATH", node_path) + else + System.delete_env("NODE_PATH") + end + end) + + System.put_env("NODE_PATH", "inherited-node-path") + binary = Path.expand("../fixtures/tailwindcss-echoes-node-path", __DIR__) + + opts = [ + canonical_tailwind: [ + binary: binary, + cd: File.cwd!(), + env: %{"NODE_PATH" => nil} + ] + ] + + assert Canonicalizer.canonicalize("p-0 flex", opts) == "unset" + assert System.get_env("NODE_PATH") == "inherited-node-path" + end + test "reuses one CLI for an env change that resolves to the same config" do assert Canonicalizer.canonicalize("p-0 flex", []) == "flex p-0" [worker] = running_workers() diff --git a/test/canonical_tailwind/config_test.exs b/test/canonical_tailwind/config_test.exs index 1462a55..678a1d0 100644 --- a/test/canonical_tailwind/config_test.exs +++ b/test/canonical_tailwind/config_test.exs @@ -93,6 +93,39 @@ defmodule CanonicalTailwind.ConfigTest do cd: File.cwd!() ] + describe ":env" do + test "is empty when neither the profile nor the formatter opts set it" do + config = resolve_with_env([test_profile: @profile_config], profile: :test_profile) + + assert config.env == %{} + end + + test "is taken from the profile" do + assert env_from_profile(%{"NODE_PATH" => "deps"}) == %{"NODE_PATH" => "deps"} + end + + test "accepts a keyword list as well as a map" do + assert env_from_profile([{"NODE_PATH", "deps"}]) == %{"NODE_PATH" => "deps"} + end + + test "joins a list value with the path separator" do + assert env_from_profile(%{"NODE_PATH" => ["deps", "_build/test"]}) == + %{"NODE_PATH" => "deps:_build/test"} + end + + test "set in the formatter opts, overrides the profile's" do + profile = Keyword.put(@profile_config, :env, %{"NODE_PATH" => "deps"}) + + config = + resolve_with_env([env_profile: profile], + profile: :env_profile, + env: %{"NODE_PATH" => "elsewhere"} + ) + + assert config.env == %{"NODE_PATH" => "elsewhere"} + end + end + describe ":input" do test "when not specified, no --css is passed to the binary" do config = resolve_with_env([bare_profile: [args: []]], profile: :bare_profile) @@ -203,4 +236,10 @@ defmodule CanonicalTailwind.ConfigTest do defp resolve_with_env(tailwind_env, opts \\ []) do Config.resolve!([canonical_tailwind: opts], tailwind_env) end + + defp env_from_profile(env) do + profile = Keyword.put(@profile_config, :env, env) + + resolve_with_env([env_profile: profile], profile: :env_profile).env + end end diff --git a/test/fixtures/tailwindcss-echoes-node-path b/test/fixtures/tailwindcss-echoes-node-path new file mode 100755 index 0000000..018b0a3 --- /dev/null +++ b/test/fixtures/tailwindcss-echoes-node-path @@ -0,0 +1,10 @@ +#!/bin/bash +# Simulates a tailwindcss CLI that replies with the NODE_PATH it was given. +case "$1" in + --help) echo "tailwindcss help (no version)"; exit 0 ;; +esac + +while read -r _line; do + echo "${NODE_PATH-unset}" +done +exit 0