diff --git a/README.md b/README.md index b8c88f9..0f1bd89 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,23 @@ This requires [Zig 0.15.2+](https://ziglang.org/download/). TailwindCompiler.compile(["flex", "p-4", "hover:bg-blue-500/50", "sm:text-lg"]) #=> {:ok, ".flex{display:flex}.p-4{padding:calc(var(--spacing)*4)}..."} +# Extract candidate strings from raw HTML/template source +TailwindCompiler.candidates(~s(
)) +#=> ["div", "flex", "p-4", "hover:bg-blue-500/50", "/div"] + +# Extract candidates lazily from a file or IO stream +File.stream!("index.html", [], :line) +|> TailwindCompiler.candidates() +|> Enum.to_list() + +# Compile raw HTML/template source by extracting candidates first +TailwindCompiler.compile_source(~s(
)) +#=> {:ok, ".flex{display:flex}.p-4{padding:calc(var(--spacing)*4)}..."} + +# compile_source/2 also accepts streams +File.stream!("index.html", [], :line) +|> TailwindCompiler.compile_source(preflight: false) + # Without preflight (base CSS reset) TailwindCompiler.compile(["flex", "hidden"], preflight: false) diff --git a/lib/tailwind_compiler.ex b/lib/tailwind_compiler.ex index 55e56b6..a9602a6 100644 --- a/lib/tailwind_compiler.ex +++ b/lib/tailwind_compiler.ex @@ -12,6 +12,66 @@ defmodule TailwindCompiler do """ + @doc """ + Extract Tailwind candidate strings from source text. + + This accepts raw HTML, templates, JavaScript/Elixir/Lua string literals, or + any other source-like text. It intentionally over-collects candidate-like + tokens; use `validate/1` or `compile/2` to let the compiler decide which + tokens are real utilities. + + When passed a binary or iodata, this returns a list. When passed an enumerable + such as `IO.stream/2` or `File.stream!/3`, this returns a lazy stream of + unique candidate strings. + + ## Examples + + TailwindCompiler.candidates(~s(
)) + #=> ["div", "flex", "p-4", "/div"] + + File.stream!("index.html", [], :line) + |> TailwindCompiler.candidates() + |> Enum.to_list() + + """ + @spec candidates(iodata()) :: [String.t()] + @spec candidates(Enumerable.t()) :: Enumerable.t() + def candidates(source) when is_binary(source) or is_list(source) do + TailwindCompiler.Candidates.extract(source) + end + + def candidates(source) do + if Enumerable.impl_for(source) do + TailwindCompiler.Candidates.stream(source) + else + raise ArgumentError, "expected source to be iodata or an enumerable of iodata chunks" + end + end + + @doc """ + Extract candidates from source text and compile them into CSS. + + This is a convenience wrapper around `candidates/1` and `compile/2`. + """ + @spec compile_source(iodata() | Enumerable.t(), keyword()) :: + {:ok, String.t()} | {:error, term()} + def compile_source(source, opts \\ []) do + source + |> candidates() + |> compile(opts) + end + + @doc """ + Same as `compile_source/2` but raises on error. + """ + @spec compile_source!(iodata() | Enumerable.t(), keyword()) :: String.t() + def compile_source!(source, opts \\ []) do + case compile_source(source, opts) do + {:ok, css} -> css + {:error, reason} -> raise "TailwindCompiler.compile_source!/2 failed: #{inspect(reason)}" + end + end + @doc """ Compile a list of Tailwind CSS candidate strings into minified CSS. @@ -50,8 +110,11 @@ defmodule TailwindCompiler do #=> {:ok, "...bg-primary{background-color:var(--color-primary)}..."} """ - @spec compile([String.t()], keyword()) :: {:ok, String.t()} | {:error, term()} - def compile(candidates, opts \\ []) when is_list(candidates) do + @spec compile([String.t()] | Enumerable.t(), keyword()) :: + {:ok, String.t()} | {:error, term()} + def compile(candidates, opts \\ []) + + def compile(candidates, opts) when is_list(candidates) do theme_json = Keyword.get(opts, :theme) preflight = Keyword.get(opts, :preflight, true) minify = Keyword.get(opts, :minify, true) @@ -66,7 +129,15 @@ defmodule TailwindCompiler do str when is_binary(str) -> str end - case TailwindCompiler.NIF.compile(candidates, theme_json || "", preflight, minify, custom_css || "", custom_utilities_json, plugin_css || "") do + case TailwindCompiler.NIF.compile( + candidates, + theme_json || "", + preflight, + minify, + custom_css || "", + custom_utilities_json, + plugin_css || "" + ) do result when is_binary(result) -> {:ok, result} error -> {:error, error} end @@ -74,6 +145,16 @@ defmodule TailwindCompiler do e -> {:error, Exception.message(e)} end + def compile(candidates, opts) do + if Enumerable.impl_for(candidates) do + candidates + |> Enum.to_list() + |> compile(opts) + else + {:error, "expected candidates to be a list or enumerable of strings"} + end + end + @doc """ Same as `compile/2` but raises on error. """ diff --git a/lib/tailwind_compiler/candidates.ex b/lib/tailwind_compiler/candidates.ex new file mode 100644 index 0000000..5e6cd62 --- /dev/null +++ b/lib/tailwind_compiler/candidates.ex @@ -0,0 +1,280 @@ +defmodule TailwindCompiler.Candidates do + @moduledoc """ + Extracts Tailwind candidate tokens from source text. + + Tailwind source detection treats files as plain text and looks for tokens + that could be class names. This module follows that model: it intentionally + over-collects candidate-like tokens and leaves final utility validation to the + compiler. + """ + + import NimbleParsec + + @candidate_chars [ + ?a..?z, + ?A..?Z, + ?0..?9, + ?_, + ?-, + ?:, + ?/, + ?., + ?%, + ?#, + ?[, + ?], + ?(, + ?), + ?{, + ?}, + ?,, + ?&, + ?>, + ?*, + ?=, + ?!, + ?@, + ??, + ?$, + ?+, + ?~, + ?|, + ?', + ?" + ] + + @candidate_bytes Enum.flat_map(@candidate_chars, fn + first..last//_ -> Enum.to_list(first..last) + char -> [char] + end) + @skip_chars Enum.map(@candidate_chars, &{:not, &1}) + + token = ascii_string(@candidate_chars, min: 1) + skip = ignore(ascii_string(@skip_chars, min: 1)) + + defparsecp(:parse_tokens, repeat(choice([token, skip]))) + + @doc """ + Extracts unique candidate-like tokens from source text or iodata. + """ + @spec extract(iodata()) :: [String.t()] + def extract(source) when is_binary(source) or is_list(source) do + source + |> IO.iodata_to_binary() + |> unique_candidates(MapSet.new()) + |> elem(0) + end + + @doc """ + Lazily extracts unique candidate-like tokens from an enumerable of iodata chunks. + + This is intended for streams such as `IO.stream/2` and `File.stream!/3`. The + implementation keeps a small trailing token carry so candidate strings split + across chunk boundaries are still emitted as a single token. + """ + @spec stream(Enumerable.t()) :: Enumerable.t() + def stream(chunks) do + chunks + |> Stream.concat(["\n"]) + |> Stream.transform({MapSet.new(), ""}, fn chunk, {seen, carry} -> + source = carry <> IO.iodata_to_binary(chunk) + {parseable, next_carry} = split_carry(source) + {tokens, seen} = unique_candidates(parseable, seen) + + {tokens, {seen, next_carry}} + end) + end + + defp parse(source) do + case parse_tokens(source) do + {:ok, tokens, "", _context, _line, _offset} -> + tokens + + {:ok, tokens, rest, _context, _line, _offset} -> + tokens ++ fallback_tokens(rest) + + {:error, _reason, rest, _context, _line, _offset} -> + fallback_tokens(rest) + end + end + + defp fallback_tokens(source) do + Regex.scan(~r/[A-Za-z0-9_\-:\/\.%#\[\]\(\)\{\},&>\*=!@\?\$\+~|'"]+/u, source) + |> List.flatten() + end + + defp unescape_string_boundaries(source) do + source + |> String.replace("\\\"", "\"") + |> String.replace("\\'", "'") + end + + defp split_carry(source) do + size = byte_size(source) + carry_start = carry_start(source, size) + + { + binary_part(source, 0, carry_start), + binary_part(source, carry_start, size - carry_start) + } + end + + defp carry_start(_source, 0), do: 0 + + defp carry_start(source, index) do + byte = :binary.at(source, index - 1) + + if candidate_byte?(byte) do + carry_start(source, index - 1) + else + index + end + end + + defp candidate_byte?(byte), do: byte in @candidate_bytes + + defp unique_candidates("", seen), do: {[], seen} + + defp unique_candidates(source, seen) do + source + |> unescape_string_boundaries() + |> parse() + |> Enum.reduce({[], seen}, fn token, {tokens, seen} -> + token = normalize(token) + + cond do + reject?(token) -> + {tokens, seen} + + MapSet.member?(seen, token) -> + {tokens, seen} + + true -> + {[token | tokens], MapSet.put(seen, token)} + end + end) + |> then(fn {tokens, seen} -> {Enum.reverse(tokens), seen} end) + end + + defp normalize(token) do + token + |> strip_code_prefix() + |> trim_boundaries() + |> strip_attr_prefix() + |> trim_boundaries() + |> strip_code_suffix() + |> strip_html_suffix() + |> String.trim_trailing(">") + |> String.trim_trailing("/") + |> String.trim_leading("=") + |> String.trim_trailing("=") + |> String.trim_trailing(".") + |> String.trim_trailing(",") + |> String.trim_trailing(";") + end + + defp strip_attr_prefix(token) do + case String.split(token, "=", parts: 2) do + [attr, value] when attr in ["class", "className"] -> trim_attr_value(value) + _other -> token + end + end + + defp strip_code_prefix(token) do + token + |> String.to_charlist() + |> strip_code_prefix([], 0) + |> List.to_string() + end + + defp strip_code_prefix([], acc, _bracket_depth), do: Enum.reverse(acc) + + defp strip_code_prefix([?\\, quote | rest], acc, 0) when quote in [?\", ?', ?`] do + if code_prefix?(acc) do + rest + else + strip_code_prefix([quote | rest], [?\\ | acc], 0) + end + end + + defp strip_code_prefix([quote | rest], acc, 0) when quote in [?\", ?', ?`] do + if code_prefix?(acc) do + rest + else + strip_code_prefix(rest, [quote | acc], 0) + end + end + + defp strip_code_prefix([?[ | rest], acc, bracket_depth) do + strip_code_prefix(rest, [?[ | acc], bracket_depth + 1) + end + + defp strip_code_prefix([?] | rest], acc, bracket_depth) do + strip_code_prefix(rest, [?] | acc], max(bracket_depth - 1, 0)) + end + + defp strip_code_prefix([char | rest], acc, bracket_depth) do + strip_code_prefix(rest, [char | acc], bracket_depth) + end + + defp code_prefix?(acc) do + not Enum.any?(acc, &(&1 in [?\", ?', ?`])) and quote_opener?(acc) + end + + defp quote_opener?([]), do: true + defp quote_opener?([char | _acc]), do: char in [?=, ?{, ?(, ?[, ?,, ?:, ??] + + defp strip_code_suffix(token) do + token + |> String.to_charlist() + |> strip_code_suffix([], 0) + |> List.to_string() + end + + defp strip_code_suffix([], acc, _bracket_depth), do: Enum.reverse(acc) + + defp strip_code_suffix([?\\, quote | _rest], acc, 0) when quote in [?\", ?', ?`] do + Enum.reverse(acc) + end + + defp strip_code_suffix([quote | _rest], acc, 0) when quote in [?\", ?', ?`] do + Enum.reverse(acc) + end + + defp strip_code_suffix([?[ | rest], acc, bracket_depth) do + strip_code_suffix(rest, [?[ | acc], bracket_depth + 1) + end + + defp strip_code_suffix([?] | rest], acc, bracket_depth) do + strip_code_suffix(rest, [?] | acc], max(bracket_depth - 1, 0)) + end + + defp strip_code_suffix([char | rest], acc, bracket_depth) do + strip_code_suffix(rest, [char | acc], bracket_depth) + end + + defp strip_html_suffix(token) do + token + |> String.split(["\">", "'>"], parts: 2) + |> hd() + end + + defp trim_attr_value(value) do + value + |> trim_boundaries() + |> String.split(["\"", "'", "`"], parts: 2) + |> hd() + end + + defp trim_boundaries(token) do + Enum.reduce(["'", "\"", "`", "\\"], token, fn boundary, token -> + token + |> String.trim_leading(boundary) + |> String.trim_trailing(boundary) + end) + end + + defp reject?(""), do: true + defp reject?(token) when byte_size(token) > 512, do: true + defp reject?(_token), do: false +end diff --git a/mix.exs b/mix.exs index 1e7c4cb..77a85f2 100644 --- a/mix.exs +++ b/mix.exs @@ -20,8 +20,13 @@ defmodule TailwindCompiler.MixProject do defp deps do [ - {:zigler, github: "bcardarella/zigler", runtime: false, optional: true}, + {:zigler, github: "bcardarella/zigler", runtime: false, optional: not force_build?()}, + {:nimble_parsec, "~> 1.4"}, {:jason, "~> 1.4", runtime: false} ] end + + defp force_build? do + System.get_env("TAILWIND_COMPILER_PATH") != nil + end end diff --git a/src/compiler.zig b/src/compiler.zig index 8113b53..0d1aee0 100644 --- a/src/compiler.zig +++ b/src/compiler.zig @@ -134,11 +134,14 @@ pub const Context = struct { const body = css[body_start..body_end]; // Check if this is a :root or [data-theme] block - if (std.mem.eql(u8, selector, ":root") or - std.mem.startsWith(u8, selector, "[data-theme")) - { - // Parse CSS declarations within this block for --color-* variables - try self.parseColorVariables(body); + if (std.mem.eql(u8, selector, ":root")) { + // Root variables define the default theme and may override + // existing defaults. + try self.parseColorVariables(body, true); + } else if (std.mem.startsWith(u8, selector, "[data-theme")) { + // Tenant/theme-scoped variables make names available to the + // compiler, but should not replace the default :root value. + try self.parseColorVariables(body, false); } // Move past the closing brace @@ -148,7 +151,7 @@ pub const Context = struct { /// Parse CSS declarations within a block body to find --color-* variable definitions. /// E.g., "--color-primary: oklch(62.3% 0.214 259.815);" → registers as theme color. - fn parseColorVariables(self: *Context, body: []const u8) !void { + fn parseColorVariables(self: *Context, body: []const u8, allow_override: bool) !void { var pos: usize = 0; while (pos < body.len) { @@ -180,9 +183,11 @@ pub const Context = struct { // If this is a --color-* variable, register it in the theme if (std.mem.startsWith(u8, property, "--color-")) { - const duped_name = try self.alloc.dupe(u8, property); - const duped_value = try self.alloc.dupe(u8, value); - try self.theme.variables.put(duped_name, duped_value); + if (allow_override or !self.theme.variables.contains(property)) { + const duped_name = try self.alloc.dupe(u8, property); + const duped_value = try self.alloc.dupe(u8, value); + try self.theme.variables.put(duped_name, duped_value); + } } // Move past the semicolon @@ -263,7 +268,10 @@ pub const Context = struct { var num_has_dot = false; for (val.value) |c| { if (c == '.') { - if (num_has_dot) { num_valid = false; break; } + if (num_has_dot) { + num_valid = false; + break; + } num_has_dot = true; } else if (c < '0' or c > '9') { num_valid = false; @@ -1839,6 +1847,26 @@ test "compile: plugin_css includes data-theme blocks" { try std.testing.expect(std.mem.indexOf(u8, result, "[data-theme=\"dark\"]") != null); } +test "compile: data-theme plugin colors do not overwrite root defaults" { + const alloc = std.testing.allocator; + const candidates = [_][]const u8{ "bg-primary", "hover:bg-primary/50", "data-[state=open]:border-primary" }; + const plugin = + \\:root { + \\ --color-primary: #3f3cbb; + \\ --color-secondary: oklch(70% 0.2 260); + \\} + \\[data-theme="harbor"] { + \\ --color-primary: #004455; + \\} + ; + const result = try compile(alloc, &candidates, null, false, true, null, null, plugin); + defer alloc.free(result); + try std.testing.expect(std.mem.indexOf(u8, result, "@layer theme{:root,:host{--color-primary:#3f3cbb") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "@layer theme{:root,:host{--color-primary:#004455") == null); + try std.testing.expect(std.mem.indexOf(u8, result, "[data-theme=\"harbor\"]{--color-primary:#004455;") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "color-mix(in srgb, #3f3cbb 50%, transparent)") != null); +} + test "compile: plugin_css colors work with opacity modifiers" { const alloc = std.testing.allocator; const candidates = [_][]const u8{"bg-brand/50"}; diff --git a/test/tailwind_compiler/candidates_test.exs b/test/tailwind_compiler/candidates_test.exs new file mode 100644 index 0000000..dd10ab2 --- /dev/null +++ b/test/tailwind_compiler/candidates_test.exs @@ -0,0 +1,216 @@ +defmodule TailwindCompiler.CandidatesTest do + use ExUnit.Case, async: true + + test "extracts candidates from raw HTML" do + source = + ~s(
) + + candidates = TailwindCompiler.candidates(source) + + assert "flex" in candidates + assert "p-4" in candidates + assert "hover:bg-blue-500/50" in candidates + assert "sm:text-lg" in candidates + assert "w-[137px]" in candidates + assert "[&>*]:p-2" in candidates + assert "before:content-['New']" in candidates + end + + test "extracts candidates from iodata" do + source = [ + ~s(
)], + ~s(Link
) + ] + + candidates = TailwindCompiler.candidates(source) + + assert "grid" in candidates + assert "grid-cols-[1fr_auto]" in candidates + assert "gap-4" in candidates + assert "underline" in candidates + assert "hover:text-blue-600" in candidates + end + + test "extracts candidates from escaped template strings" do + source = + ~S''' + return "
" + ''' + + candidates = TailwindCompiler.candidates(source) + + assert "grid" in candidates + assert "grid-cols-[1fr_auto]" in candidates + assert "hover:bg-blue-500/50" in candidates + end + + test "trims TypeScript string suffixes while preserving arbitrary value quotes" do + source = + ~S''' + const classes = cn( + "[&_tr]:border-b", + "[&>[role=checkbox]]:translate-y-[2px]", + "*:[span]:last:gap-2", + "[a&]:hover:bg-primary/90", + { 4: "grid-cols-4", danger: "bg-orange-500" }, + readOnly ? "cursor-default" : "cursor-move", + "before:content-['New']" + ); + return ; + + ''' + + candidates = TailwindCompiler.candidates(source) + + assert "[&_tr]:border-b" in candidates + assert "[&>[role=checkbox]]:translate-y-[2px]" in candidates + assert "*:[span]:last:gap-2" in candidates + assert "[a&]:hover:bg-primary/90" in candidates + assert "grid-cols-4" in candidates + assert "bg-orange-500" in candidates + assert "cursor-move" in candidates + assert "before:content-['New']" in candidates + assert "w-1/3" in candidates + end + + test "extracts arbitrary variants from multi-class TypeScript strings" do + source = + ~S''' + className={cn( + "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className, + )} + ''' + + candidates = TailwindCompiler.candidates(source) + + assert "text-foreground" in candidates + assert "[&:has([role=checkbox])]:pr-0" in candidates + assert "[&>[role=checkbox]]:translate-y-[2px]" in candidates + end + + test "returns a lazy stream for enumerable input" do + test_process = self() + + source = + Stream.map( + [ + ~s(
), + ~s(
) + ], + fn chunk -> + send(test_process, {:chunk_read, chunk}) + chunk + end + ) + + candidates = TailwindCompiler.candidates(source) + + refute_received {:chunk_read, _chunk} + assert Enum.to_list(candidates) == ["div", "flex", "p-4", "/div", "md:grid"] + assert_received {:chunk_read, _chunk} + end + + test "extracts candidates from IO streams" do + path = + Path.join( + System.tmp_dir!(), + "tailwind_compiler_candidates_#{System.unique_integer([:positive])}.html" + ) + + File.write!(path, """ +
+
+
+ """) + + try do + File.open!(path, [:read], fn io -> + candidates = + io + |> IO.stream(:line) + |> TailwindCompiler.candidates() + |> Enum.to_list() + + assert "mx-auto" in candidates + assert "max-w-4xl" in candidates + assert "prose" in candidates + assert "hover:prose-a:text-blue-600" in candidates + end) + after + File.rm(path) + end + end + + test "keeps candidates intact across stream chunk boundaries" do + source = + [ + ~s(
) + ] + |> Stream.map(& &1) + + candidates = + source + |> TailwindCompiler.candidates() + |> Enum.to_list() + + assert "grid" in candidates + assert "grid-cols-[1fr_auto]" in candidates + assert "hover:bg-blue-500/50" in candidates + end + + test "deduplicates candidates across stream chunks" do + source = + [ + ~s(
), + ~s(
) + ] + |> Stream.map(& &1) + + candidates = + source + |> TailwindCompiler.candidates() + |> Enum.to_list() + + assert Enum.count(candidates, &(&1 == "flex")) == 1 + assert Enum.count(candidates, &(&1 == "p-4")) == 1 + assert "md:flex" in candidates + end + + test "compiles source after extracting candidates" do + source = ~s(
) + + assert {:ok, css} = TailwindCompiler.compile_source(source, preflight: false) + assert css =~ ".flex" + assert css =~ ".p-4" + assert css =~ "hover\\:bg-blue-500\\/50" + end + + test "compiles candidate streams" do + candidates = + ["flex", "p-4", "hover:bg-blue-500/50"] + |> Stream.map(& &1) + + assert {:ok, css} = TailwindCompiler.compile(candidates, preflight: false) + assert css =~ ".flex" + assert css =~ ".p-4" + assert css =~ "hover\\:bg-blue-500\\/50" + end + + test "compiles streamed source after extracting candidates" do + source = + [ + ~s(
) + ] + |> Stream.map(& &1) + + assert {:ok, css} = TailwindCompiler.compile_source(source, preflight: false) + assert css =~ ".flex" + assert css =~ ".p-4" + assert css =~ "hover\\:bg-blue-500\\/50" + end +end diff --git a/test/tailwind_compiler_test.exs b/test/tailwind_compiler_test.exs index cdee4c5..8aea4c5 100644 --- a/test/tailwind_compiler_test.exs +++ b/test/tailwind_compiler_test.exs @@ -39,10 +39,38 @@ defmodule TailwindCompilerTest do end test "accepts theme overrides" do - {:ok, css} = TailwindCompiler.compile(["p-4"], theme: ~s({"spacing":"0.5rem"}), preflight: false) + {:ok, css} = + TailwindCompiler.compile(["p-4"], theme: ~s({"spacing":"0.5rem"}), preflight: false) + assert css =~ "--spacing:0.5rem" end + test "accepts flat and nested theme color overrides" do + {:ok, flat_css} = + TailwindCompiler.compile( + ["bg-brand", "hover:bg-brand/50", "dark:text-brand"], + theme: ~s({"colors":{"brand":"#3f3cbb"}}), + preflight: false + ) + + assert flat_css =~ "--color-brand:#3f3cbb" + assert flat_css =~ "background-color:var(--color-brand)" + assert flat_css =~ "color-mix(in srgb, #3f3cbb 50%, transparent)" + assert flat_css =~ "@media (prefers-color-scheme:dark)" + + {:ok, nested_css} = + TailwindCompiler.compile( + ["bg-brand-500", "text-brand-700/60"], + theme: ~s({"colors":{"brand":{"500":"#3f3cbb","700":"#25206b"}}}), + preflight: false + ) + + assert nested_css =~ "--color-brand-500:#3f3cbb" + assert nested_css =~ "--color-brand-700:#25206b" + assert nested_css =~ "background-color:var(--color-brand-500)" + assert nested_css =~ "color-mix(in srgb, #25206b 60%, transparent)" + end + test "handles empty candidate list" do {:ok, css} = TailwindCompiler.compile([], preflight: false) assert css == "" or css =~ "@layer" @@ -69,7 +97,8 @@ defmodule TailwindCompilerTest do test "deduplicates candidates" do {:ok, css} = TailwindCompiler.compile(["flex", "flex", "flex"], preflight: false) count = css |> String.split(".flex{") |> length() - assert count == 2 # split produces 2 parts for 1 occurrence + # split produces 2 parts for 1 occurrence + assert count == 2 end test "accepts custom CSS" do @@ -322,6 +351,36 @@ defmodule TailwindCompilerTest do assert css =~ "[data-theme=\"dark\"]" end + test "data-theme plugin colors do not overwrite root defaults" do + plugin_css = """ + :root { + --color-primary: #3f3cbb; + --color-secondary: oklch(70% 0.2 260); + } + + [data-theme="harbor"] { + --color-primary: #004455; + } + """ + + {:ok, css} = + TailwindCompiler.compile( + ["bg-primary", "hover:bg-primary/50", "data-[state=open]:border-primary"], + plugin_css: plugin_css, + preflight: false + ) + + assert css =~ "[data-theme=\"harbor\"]{--color-primary:#004455;" + assert css =~ "background-color:var(--color-primary)" + assert css =~ "&[data-state=open]{border-color:var(--color-primary)}" + + if System.get_env("TAILWIND_COMPILER_PATH") do + assert css =~ "@layer theme{:root,:host{--color-primary:#3f3cbb" + refute css =~ "@layer theme{:root,:host{--color-primary:#004455" + assert css =~ "color-mix(in srgb, #3f3cbb 50%, transparent)" + end + end + test "plugin colors work with opacity modifiers", %{plugin_css: plugin_css} do {:ok, css} = TailwindCompiler.compile(