Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/canonical_tailwind/canonicalizer/worker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
24 changes: 23 additions & 1 deletion lib/canonical_tailwind/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -35,6 +36,7 @@ defmodule CanonicalTailwind.Config do
args: args,
binary: binary,
cd: cd,
env: env,
timeout: timeout
}
end
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions test/canonical_tailwind/canonicalizer/worker_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
42 changes: 42 additions & 0 deletions test/canonical_tailwind/canonicalizer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
39 changes: 39 additions & 0 deletions test/canonical_tailwind/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions test/fixtures/tailwindcss-echoes-node-path
Original file line number Diff line number Diff line change
@@ -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