defmodule TailwindFormatter do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
alias TailwindFormatter.{Order, HEExTokenizer}
@behaviour Mix.Tasks.Format
@placeholder "💧"
def features(_opts) do
[sigils: [:H], extensions: [".heex"]]
end
def format(contents, _opts) do
contents
|> HEExTokenizer.tokenize()
|> Enum.reduce([contents], fn
{elt, _name, attrs, _meta}, contents
when elt in [:tag, :local_component, :remote_component] ->
Enum.reduce(attrs, contents, fn
{"class", class_attr, _meta}, [remainder | acc] ->
[attr, remainder] = String.split(remainder, old_classes(class_attr), parts: 2)
[remainder, sort_classes(class_attr), attr | acc]
_, contents ->
contents
end)
_, contents ->
contents
end)
|> Enum.reverse()
|> Enum.join()
end
defp old_classes({_type, classes, _meta}), do: classes
defp sort_classes({:string, classes, _meta}), do: sort(classes)
defp sort_classes({:expr, expr_class, _meta}) do
expr_class
|> Code.string_to_quoted!(literal_encoder: &{:ok, {:__block__, &2, [&1]}})
|> sort_expr()
|> Code.quoted_to_algebra()
|> Inspect.Algebra.format(:infinity)
|> IO.iodata_to_binary()
end
defp sort_expr({:<<>>, meta, children}), do: {:<<>>, meta, handle_interpolation(children)}
defp sort_expr({a, b, c}), do: {sort_expr(a), sort_expr(b), sort_expr(c)}
defp sort_expr({a, b}), do: {sort_expr(a), sort_expr(b)}
defp sort_expr(list) when is_list(list), do: Enum.map(list, &sort_expr/1)
defp sort_expr(text) when is_binary(text), do: sort(text)
defp sort_expr(node), do: node
defp handle_interpolation(children) do
{classes_with_placeholders, {placeholder_map, _index}} =
Enum.map_reduce(children, {%{}, 0}, fn
str, acc when is_binary(str) ->
{str, acc}
node, {placeholder_map, index} ->
{"#{@placeholder}#{index}#{@placeholder}",
{Map.put(placeholder_map, "#{index}", sort_expr(node)), index + 1}}
end)
classes_with_placeholders
|> Enum.reduce("", fn class, acc ->
if placeholder?(class) or String.starts_with?(class, "-"),
do: acc <> class,
else: "#{acc} #{class}"
end)
|> sort()
|> String.split()
|> weave_in_code(placeholder_map)
end
defp weave_in_code(classes, placeholder_map) do
classes
|> Enum.map(fn class ->
if placeholder?(class) do
[prefix, index, suffix] = String.split(class, @placeholder)
[prefix, Map.fetch!(placeholder_map, index), suffix]
else
class
end
end)
|> Enum.intersperse(" ")
|> List.flatten()
end
defp sort_variant_chains(classes) do
classes
|> String.split(~r/\s+(?![^\[]*\])/i, trim: true)
|> Enum.map(fn class ->
class
|> String.split(":")
|> Enum.sort_by(&variant_position/1, :desc)
|> Enum.join(":")
end)
end
defp sort(classes) when is_binary(classes) do
leading_space = if classes =~ ~r/\A\s/, do: " "
trailing_space = if classes =~ ~r/\s\z/, do: " "
classes =
classes
|> sort_variant_chains()
|> sort()
|> Enum.join(" ")
Enum.join([leading_space, classes, trailing_space])
end
defp sort([]), do: []
defp sort(class_list) when is_list(class_list) do
{variants, base_classes} = Enum.split_with(class_list, &variant?/1)
Enum.sort_by(base_classes, &class_position/1) ++ sort_variant_classes(variants)
end
defp placeholder?(class), do: String.contains?(class, @placeholder)
defp variant?(class), do: String.contains?(class, ":") and not String.starts_with?(class, "[")
defp prose?(class), do: String.contains?(class, "prose")
defp class_position(class),
do: if(placeholder?(class), do: -1_000_000, else: Map.get(Order.classes(), class, -1))
# prose variant order matters, thus push to front
defp variant_position(variant),
do: if(prose?(variant), do: 0, else: Map.get(Order.variants(), variant, -1))
defp sort_variant_classes(variants) do
variants
|> group_by_first_variant()
|> Enum.sort_by(fn {variant, _rest} -> variant_position(variant) end)
|> Enum.map(fn {variant, rest} -> {variant, sort(rest)} end)
|> Enum.flat_map(fn {variant, rest} -> Enum.map(rest, &"#{variant}:#{&1}") end)
end
defp group_by_first_variant(variants) do
variants
|> Enum.map(&String.split(&1, ":", parts: 2))
|> Enum.group_by(&List.first/1, &List.last/1)
end
end