Current section

11 Versions

Jump to

Compare versions

4 files changed
+63 additions
-22 deletions
  @@ -1,5 +1,10 @@
1 1 # Changelog
2 2
3 + ## v0.3.2 (2026-07-18)
4 +
5 + ### Bug fixes
6 + - [CanonicalTailwind] Keep `~w`/`~W` sigils in `class` attributes as word lists: each class is normalized in place, never sorted or collapsed against the others. Previously `~w(w-100 w-140 w-60)` was collapsed to a single class, silently dropping runtime elements on format. ([#8](https://github.com/aptinio/canonical_tailwind/issues/8))
7 +
3 8 ## v0.3.1 (2026-07-04)
4 9
5 10 ### Bug fixes
  @@ -2,7 +2,7 @@
2 2 [{<<"Changelog">>,<<"https://hexdocs.pm/canonical_tailwind/changelog.html">>},
3 3 {<<"GitHub">>,<<"https://github.com/aptinio/canonical_tailwind">>}]}.
4 4 {<<"name">>,<<"canonical_tailwind">>}.
5 - {<<"version">>,<<"0.3.1">>}.
5 + {<<"version">>,<<"0.3.2">>}.
6 6 {<<"description">>,
7 7 <<"Canonicalizes Tailwind CSS utility classes in HEEx templates and Elixir code">>}.
8 8 {<<"elixir">>,<<"~> 1.18">>}.
  @@ -70,27 +70,21 @@ defmodule CanonicalTailwind do
70 70 {sigil, meta, [{:<<>>, bin_meta, [canonicalized]}, mods]}
71 71 end
72 72
73 - defp canonicalize_ast({:<<>>, meta, segments}, opts) do
73 + defp canonicalize_ast({sigil, meta, [{:<<>>, bin_meta, segments}, mods]}, opts) when sigil in [:sigil_w, :sigil_W] do
74 + # A word-list sigil desugars to a list of independent classes: normalize each
75 + # word in place, but never sort or collapse them against each other. Modifiers
76 + # (~w()a, ~w()c) are canonicalized the same way; in a class attribute the words
77 + # are class data regardless of the produced element type.
74 78 segments =
75 - Enum.map(segments, fn
76 - seg when is_binary(seg) -> seg
77 - interp -> canonicalize_ast(interp, opts)
78 - end)
79 -
80 - canonicalized =
81 79 segments
82 - |> Enum.with_index()
83 - |> Enum.map(fn
84 - {binary, i} when is_binary(binary) ->
85 - prev_interp? = i > 0 and not is_binary(Enum.at(segments, i - 1))
86 - next_interp? = i < length(segments) - 1 and not is_binary(Enum.at(segments, i + 1))
87 - canonicalize_segment(binary, prev_interp?, next_interp?, opts)
80 + |> canonicalize_segments(opts, :word_list)
81 + |> preserve_heredoc_newline_segments(meta[:delimiter])
88 82
89 - {interp, _i} ->
90 - interp
91 - end)
83 + {sigil, meta, [{:<<>>, bin_meta, segments}, mods]}
84 + end
92 85
93 - {:<<>>, meta, canonicalized}
86 + defp canonicalize_ast({:<<>>, meta, segments}, opts) do
87 + {:<<>>, meta, canonicalize_segments(segments, opts, :class_string)}
94 88 end
95 89
96 90 defp canonicalize_ast({left, right}, opts) do
  @@ -113,14 +107,52 @@ defmodule CanonicalTailwind do
113 107
114 108 defp preserve_heredoc_newline(value, _delim), do: value
115 109
116 - defp canonicalize_segment(binary, prev_interp?, next_interp?, opts) do
110 + defp preserve_heredoc_newline_segments(segments, delim) when delim in ["\"\"\"", "'''"] do
111 + # A heredoc body always ends in a newline before the closing delimiter, so its
112 + # last segment is a binary; restore the newline the flattening replaced.
113 + case List.last(segments) do
114 + last when is_binary(last) ->
115 + List.replace_at(segments, -1, String.trim_trailing(last) <> "\n")
116 +
117 + _ ->
118 + segments
119 + end
120 + end
121 +
122 + defp preserve_heredoc_newline_segments(segments, _delim), do: segments
123 +
124 + defp canonicalize_segments(segments, opts, mode) do
125 + # In :word_list mode interpolations are opaque: a runtime value spliced into
126 + # the list could be whitespace-split into independent classes, so rewriting a
127 + # nested class string as a group would risk the same collapse as a bare list.
128 + segments =
129 + Enum.map(segments, fn
130 + seg when is_binary(seg) -> seg
131 + interp when mode == :word_list -> interp
132 + interp -> canonicalize_ast(interp, opts)
133 + end)
134 +
135 + segments
136 + |> Enum.with_index()
137 + |> Enum.map(fn
138 + {binary, i} when is_binary(binary) ->
139 + prev_interp? = i > 0 and not is_binary(Enum.at(segments, i - 1))
140 + next_interp? = i < length(segments) - 1 and not is_binary(Enum.at(segments, i + 1))
141 + canonicalize_segment(binary, prev_interp?, next_interp?, opts, mode)
142 +
143 + {interp, _i} ->
144 + interp
145 + end)
146 + end
147 +
148 + defp canonicalize_segment(binary, prev_interp?, next_interp?, opts, mode) do
117 149 words = String.split(binary)
118 150
119 151 if words == [] do
120 152 binary
121 153 else
122 154 {prefix, words, suffix} = split_boundary_words(binary, words, prev_interp?, next_interp?)
123 - canonicalized = canonicalize(Enum.join(words, " "), opts)
155 + canonicalized = canonicalize_words(words, mode, opts)
124 156 leading = if String.match?(binary, ~r/^\s/), do: " ", else: ""
125 157 trailing = if String.match?(binary, ~r/\s$/), do: " ", else: ""
126 158
  @@ -149,6 +181,10 @@ defmodule CanonicalTailwind do
149 181 {prefix, words, suffix}
150 182 end
151 183
184 + defp canonicalize_words(words, :class_string, opts), do: canonicalize(Enum.join(words, " "), opts)
185 +
186 + defp canonicalize_words(words, :word_list, opts), do: Enum.map_join(words, " ", &canonicalize(&1, opts))
187 +
152 188 defp canonicalize(class_string, opts) do
153 189 if String.trim(class_string) == "" do
154 190 class_string
  @@ -1,7 +1,7 @@
1 1 defmodule CanonicalTailwind.MixProject do
2 2 use Mix.Project
3 3
4 - @version "0.3.1"
4 + @version "0.3.2"
5 5 @source_url "https://github.com/aptinio/canonical_tailwind"
6 6
7 7 def application do
  @@ -37,7 +37,7 @@ defmodule CanonicalTailwind.MixProject do
37 37 {:tailwind, "~> 0.3", optional: true},
38 38 {:ex_doc, "~> 0.40.3", only: :dev, runtime: false},
39 39 {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
40 - {:def_layout, "~> 0.1.0", only: [:dev, :test], runtime: false},
40 + {:def_layout, "~> 0.1.1", only: [:dev, :test], runtime: false},
41 41 {:quokka, "~> 2.13", only: [:dev, :test], runtime: false}
42 42 ]
43 43 end