diff --git a/README.md b/README.md index 5962f27..76a1815 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,20 @@ ## Installation -1. **Using `salad_ui` as part of your project:** - -> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need. -> If you just want to use SaladUI's components, see **Using as library** below. - -- Adding `salad_ui` to your list of dependencies in `mix.exs`: - +1. Add `salad_ui` to your `mix.exs` ```elixir def deps do [ - {:salad_ui, "~> 0.13.0", only: [:dev]}, - {:tails, "~> 0.1"} + {:salad_ui, "~> 0.13.0"}, ] end ``` +2. **Using `salad_ui` as part of your project:** + +> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need. +> If you just want to use SaladUI's components, see **Using as library** below. + - Init Salad UI in your project ``` #> cd your_project @@ -52,18 +50,7 @@ end #> mix salad.add label button ``` -2. **Using `salad_ui` as a library:** - -- Adding `salad_ui` to your list of dependencies in `mix.exs`: - -```elixir -def deps do - [ - {:salad_ui, "~> 0.13.0", only: [:dev]} - ] -end -``` - +3. **Using `salad_ui` as a library:** - Init Salad UI in your project with option `--as-lib` ``` #> cd your_project @@ -169,6 +156,6 @@ To run the failing tests only, just run `mix test.watch --stale`. This project could not be available without these awesome works: - `tailwind css` an awesome css utility project -- `tails` for merging tailwind class +- `turboprop` I borrow code from here for merging tailwinds classes - `shadcn/ui` which this project is inspired from - `Phoenix Framework` of course diff --git a/config/config.exs b/config/config.exs index d620903..e0a4cfa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,5 +18,3 @@ config :tailwind, ), cd: Path.expand("../assets", __DIR__) ] - -config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json") diff --git a/docs/manual_install.md b/docs/manual_install.md index 51f8bbc..e1b7ddc 100644 --- a/docs/manual_install.md +++ b/docs/manual_install.md @@ -74,17 +74,7 @@ npm i -D tailwindcss-animate yarn add -D tailwindcss-animate ``` -3. Configure `tails` -SaladUI use `tails` to properly merge Tailwindcss classes - -```elixir -# config/config.exs - -config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json") -``` - - -4. **Add javascript to handle event from server** +3. **Add javascript to handle event from server** This add ability to execute client action from server. It's similar to `JS.exec/2`. Thanks to [this post](https://fly.io/phoenix-files/server-triggered-js/) from fly.io. Add this code snippet to the end of `app.js` @@ -106,7 +96,7 @@ Then from server side, you can close an opening sheet like this. end ``` -5. Some tweaks +4. Some tweaks Thanks to @ahacking - To make dark and light mode work correctly, add following to your `app.css` diff --git a/lib/mix/tasks/salad.init.ex b/lib/mix/tasks/salad.init.ex index 5084154..0289db4 100644 --- a/lib/mix/tasks/salad.init.ex +++ b/lib/mix/tasks/salad.init.ex @@ -91,9 +91,7 @@ defmodule Mix.Tasks.Salad.Init do end defp write_config(component_path) do - with :ok <- write_dev_config(component_path) do - write_tails_config() - end + write_dev_config(component_path) end defp write_dev_config(component_path) do @@ -110,20 +108,6 @@ defmodule Mix.Tasks.Salad.Init do patch_config(dev_config_path, components_config) end - defp write_tails_config do - Mix.shell().info("Writing tails config to config.exs") - config_path = Path.join(File.cwd!(), "config/config.exs") - - tails_config = [ - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] - } - ] - - patch_config(config_path, tails_config) - end - defp patch_config(config_path, config) do if File.exists?(config_path) do Patcher.patch_config(config_path, config) diff --git a/lib/salad_ui.ex b/lib/salad_ui.ex index 1c858f4..9076900 100644 --- a/lib/salad_ui.ex +++ b/lib/salad_ui.ex @@ -5,10 +5,12 @@ defmodule SaladUI do use Phoenix.Component import SaladUI.Helpers - import Tails, only: [classes: 1] # alias OrangeCmsWeb.Components.LadUI.LadJS alias Phoenix.LiveView.JS + defp classes(input) do + SaladUI.Merge.merge(input) + end end end diff --git a/lib/utils/cache.ex b/lib/utils/cache.ex new file mode 100644 index 0000000..74aa202 --- /dev/null +++ b/lib/utils/cache.ex @@ -0,0 +1,44 @@ +defmodule SaladUI.Cache do + @moduledoc false + use GenServer + + alias SaladUI.Merge.ClassTree + + @default_table_name :turboprop_cache + + @doc false + def default_table_name, do: @default_table_name + + def start_link(default) when is_list(default) do + GenServer.start_link(__MODULE__, default) + end + + @impl true + def init(opts \\ []) do + table_name = Keyword.get(opts, :cache_table_name, @default_table_name) + create_table(table_name) + + insert(:class_tree, ClassTree.generate()) + + {:ok, []} + end + + def create_table(table_name \\ @default_table_name) do + :ets.new(table_name, [:set, :public, :named_table, read_concurrency: true]) + end + + def insert(key, value, table_name \\ @default_table_name) do + :ets.insert(table_name, {key, value}) + end + + def retrieve(key, table_name \\ @default_table_name) do + case :ets.lookup(table_name, key) do + [{^key, value}] -> value + [] -> nil + end + end + + def purge(table_name \\ @default_table_name) do + :ets.delete_all_objects(table_name) + end +end diff --git a/lib/utils/merge.ex b/lib/utils/merge.ex new file mode 100644 index 0000000..c8913ca --- /dev/null +++ b/lib/utils/merge.ex @@ -0,0 +1,141 @@ +defmodule SaladUI.Merge do + @moduledoc """ + SaladUI Merge adds efficient class joining and merging of TailwindCSS classes to Elixir. + + TailwindCSS class names are composable and allow specifying an infinite amount of different styles. Most components allow overriding class + names, like as passing `class` attribute that then gets merged with the existing styles. This can result in class lists such as + `text-white bg-red-500 bg-blue-300` where `text-white bg-red-500` is the preset style, and `bg-blue-300` is the override for that one + specific button that needs to look slightly different. + Styles based on class are applied according to the order _they are defined at in the stylesheet_. In this example, because TailwindCSS + orders color definitions alphabetically, the override does not work. `blue` is defined before `red`, so the `bg-red-500` class takes + precedence since it was defined later. + + In order to still allow overriding styles, SaladUI Merge traverses the entire class list, creates a list of all classes and which + conflicting groups of styles exist in them and gives precedence to the ones that were defined last _in the class list_, which, unlike the + stylesheet, is in control of the user. + + + ## Example + + ```elixir + iex> merge("text-white bg-red-500 bg-blue-300") + "text-white bg-blue-300" + + iex> merge(["px-2 py-1 bg-red hover:bg-dark-red", "p-3 bg-[#B91C1C]"]) + "hover:bg-dark-red p-3 bg-[#B91C1C]" + ``` + + ## Configuration + + SaladUI Merge does not currently support full theme configuration - that's on the roadmap! + + The limited configuration at the moment is adding Tailwind's `prefix` option. + + ```elixir + config :turboprop, + prefix: "tw-" + ``` + """ + + alias SaladUI.Cache + alias SaladUI.Merge.Class + alias SaladUI.Merge.Config + + @doc """ + Joins and merges a list of classes. + + Passes the input to `join/1` before merging. + """ + @spec merge(list(), term()) :: binary() + def merge(input, config \\ Config.config()) do + input + |> join() + |> retrieve_from_cache_or_merge(config) + end + + @doc """ + Joins a list of classes. + """ + @spec merge(binary() | list()) :: binary() + def join(input) when is_binary(input), do: input + def join(input) when is_list(input), do: do_join(input, "") + def join(_), do: "" + + defp do_join("", result), do: result + defp do_join(nil, result), do: result + defp do_join([], result), do: result + + defp do_join(string, result) when is_binary(string), do: do_join([string], result) + + defp do_join([head | tail], result) do + case to_value(head) do + "" -> do_join(tail, result) + value when result == "" -> do_join(tail, value) + value -> do_join(tail, result <> " " <> value) + end + end + + defp to_value(value) when is_binary(value), do: value + + defp to_value(values) when is_list(values) do + Enum.reduce(values, "", fn v, acc -> + case to_value(v) do + "" -> acc + resolved_value when acc == "" -> resolved_value + resolved_value -> acc <> " " <> resolved_value + end + end) + end + + defp to_value(_), do: "" + + defp retrieve_from_cache_or_merge(classes, config) do + case Cache.retrieve("merge:#{classes}") do + nil -> + merged_classes = do_merge(classes, config) + Cache.insert("merge:#{classes}", merged_classes) + merged_classes + + merged_classes -> + merged_classes + end + end + + defp do_merge(classes, config) do + classes + |> String.trim() + |> String.split(~r/\s+/) + |> Enum.map(&Class.parse/1) + |> Enum.reverse() + |> Enum.reduce(%{classes: [], groups: []}, fn class, acc -> + handle_class(class, acc, config) + end) + |> Map.get(:classes) + |> Enum.join(" ") + end + + defp handle_class(%{raw: raw, tailwind?: false}, acc, _config), do: Map.update!(acc, :classes, fn classes -> [raw | classes] end) + + defp handle_class(%{conflict_id: conflict_id} = class, acc, config) do + if Enum.member?(acc.groups, conflict_id), do: acc, else: add_class(acc, class, config) + end + + defp add_class(acc, %{raw: raw, group: group, conflict_id: conflict_id, modifier_id: modifier_id}, config) do + conflicting_groups = + group + |> conflicting_groups(config) + |> Enum.map(&"#{modifier_id}:#{&1}") + |> then(&[conflict_id | &1]) + + acc + |> Map.update!(:classes, fn classes -> [raw | classes] end) + |> Map.update!(:groups, fn groups -> groups ++ conflicting_groups end) + end + + defp conflicting_groups(group, config) do + conflicts = Map.get(config.conflicting_groups, group, []) + modifier_conflicts = Map.get(config.conflicting_group_modifiers, group, []) + + conflicts ++ modifier_conflicts + end +end diff --git a/lib/utils/merge/class.ex b/lib/utils/merge/class.ex new file mode 100644 index 0000000..045ab80 --- /dev/null +++ b/lib/utils/merge/class.ex @@ -0,0 +1,115 @@ +defmodule SaladUI.Merge.Class do + @moduledoc false + + defstruct [:raw, :tailwind?, :group, :conflict_id, :modifiers, :modifier_id, :important?, :base] + + def parse(raw) do + with {:ok, parsed, "", _, _, _} <- SaladUI.Merge.Parser.class(raw), + base when is_binary(base) <- Keyword.get(parsed, :base), + group when is_binary(group) <- group(base), + modifier_id when is_binary(modifier_id) <- modifier_id(parsed) do + %__MODULE__{ + raw: raw, + tailwind?: true, + group: group, + conflict_id: "#{modifier_id}:#{group}", + modifiers: Keyword.get_values(parsed, :modifier), + modifier_id: modifier_id, + base: base, + important?: Keyword.has_key?(parsed, :important) + } + else + _ -> + %__MODULE__{ + raw: raw, + tailwind?: false + } + end + end + + defp group(class) do + # Don't consider negation prefix. + parts = + case String.split(class, "-") do + ["" | parts] -> parts + parts -> parts + end + + group_recursive(parts, SaladUI.Merge.ClassTree.get()) || get_group_id_for_arbitrary_property(class) + end + + defp group_recursive([], %{"group" => group}), do: group + defp group_recursive([], %{"next" => %{"" => %{"group" => group}}}), do: group + + defp group_recursive([part | rest] = path, map) do + if next = Map.get(map["next"], part) do + group_recursive(rest, next) + else + if Enum.any?(map["validators"]) do + path = Enum.join(path, "-") + + case Enum.find(sort_validators(map["validators"]), fn {_key, %{"function" => validator}} -> validator.(path) end) do + nil -> nil + {_key, %{"group" => group}} -> group + end + end + end + end + + defp group_recursive(_, _), do: nil + + defp sort_validators(map) do + map + |> Enum.to_list() + |> Enum.reject(fn {key, _value} -> key == "any?" end) + |> Enum.sort() + |> append_any?(map) + end + + defp append_any?(list, map) do + case map["any?"] do + nil -> list + any? -> list ++ [{"any?", any?}] + end + end + + defp get_group_id_for_arbitrary_property(class_name) do + arbitrary_property_regex = ~r/^\[(.+)\]$/ + + if Regex.match?(arbitrary_property_regex, class_name) do + [_, arbitrary_property_class_name] = Regex.run(arbitrary_property_regex, class_name) + + property = + arbitrary_property_class_name + |> String.split(":", parts: 2) + |> List.first() + + if property do + ".." <> property + end + end + end + + defp modifier_id(parsed_class) do + modifiers = parsed_class |> Keyword.get_values(:modifier) |> sort_modifiers() |> Enum.join(":") + if Keyword.has_key?(parsed_class, :important), do: modifiers <> "!", else: modifiers + end + + @doc """ + Sort modifiers according to the following rules: + + - All known modifiers are sorted alphabetically. + - Arbitrary modifiers retain their position. + """ + def sort_modifiers(modifiers) do + modifiers + |> Enum.reduce({[], []}, fn modifier, {sorted, unsorted} -> + if String.starts_with?(modifier, "[") do + {sorted ++ Enum.sort(unsorted) ++ [modifier], []} + else + {sorted, unsorted ++ [modifier]} + end + end) + |> then(fn {sorted, unsorted} -> sorted ++ Enum.sort(unsorted) end) + end +end diff --git a/lib/utils/merge/class_tree.ex b/lib/utils/merge/class_tree.ex new file mode 100644 index 0000000..3d103e0 --- /dev/null +++ b/lib/utils/merge/class_tree.ex @@ -0,0 +1,123 @@ +defmodule SaladUI.Merge.ClassTree do + @moduledoc false + + alias SaladUI.Cache + alias SaladUI.Merge.Config + + def get do + case Cache.retrieve(:class_tree) do + nil -> + class_tree = generate() + Cache.insert(:class_tree, class_tree) + class_tree + + class_tree -> + class_tree + end + end + + def generate do + config = Config.config() + theme = Map.get(config, :theme, %{}) + prefix = Map.get(config, :prefix) + groups = Map.get(config, :groups, []) + + groups + |> Enum.reduce(%{}, fn {group, next}, acc -> + DeepMerge.deep_merge(acc, handle_next(next, theme, group)) + end) + |> add_prefix(prefix) + end + + defp handle_next(next, theme, group) do + Enum.reduce(next, %{"next" => %{}, "validators" => %{}}, fn part, acc -> + case part do + {sub_group, sub_next} -> + add_group(sub_group, sub_next, theme, group, acc) + + part -> + add_part(part, theme, group, acc) + end + end) + end + + defp add_part(part, _theme, group, acc) when is_binary(part) do + next = + part + |> String.split("-") + |> case do + [] -> %{} + [single] -> %{single => %{"group" => group, "next" => %{}, "validators" => %{}}} + multiple -> add_part_recursive(multiple, group) + end + + Map.update(acc, "next", %{}, &DeepMerge.deep_merge(&1, next)) + end + + defp add_part(part, theme, group, acc) when is_map(part) do + DeepMerge.deep_merge(acc, handle_next(part, theme, group)) + end + + defp add_part(part, theme, group, acc) when is_function(part) do + if from_theme?(part) do + flattened_theme = flatten_theme(part, theme, group) + + acc + |> Map.update("next", %{}, &DeepMerge.deep_merge(&1, flattened_theme["next"])) + |> Map.update("validators", %{}, &DeepMerge.deep_merge(&1, flattened_theme["validators"])) + else + {:name, key} = Function.info(part, :name) + + Map.update(acc, "validators", %{}, fn validators -> + Map.put(validators, to_string(key), %{"function" => part, "group" => group}) + end) + end + end + + defp add_part_recursive([single], group) do + %{single => %{"group" => group, "next" => %{}, "validators" => %{}}} + end + + defp add_part_recursive([current | rest], group) do + %{current => %{"next" => add_part_recursive(rest, group)}} + end + + defp add_group(sub_group, sub_next, theme, group, acc) do + sub_group + |> String.split("-") + |> add_nested_group(sub_next, theme, group, acc) + end + + defp add_nested_group([current | rest], sub_next, theme, group, acc) do + Map.update(acc, "next", %{}, &Map.put(&1, current, add_nested_group(rest, List.flatten(sub_next), theme, group, acc))) + end + + defp add_nested_group([], sub_next, theme, group, _acc) do + handle_next(sub_next, theme, group) + end + + defp from_theme?(function), do: Function.info(function, :module) == {:module, Config} + + defp flatten_theme(function, theme, group, acc \\ %{"next" => %{}, "validators" => %{}}) do + Enum.reduce(function.(theme), acc, fn + item, acc when is_binary(item) -> + Map.update(acc, "next", %{}, &Map.put(&1, item, %{"next" => %{}, "validators" => %{}, "group" => group})) + + item, acc when is_function(item) -> + if from_theme?(item) do + flatten_theme(item, theme, group, acc) + else + Map.update(acc, "validators", %{}, fn validators -> + {:name, key} = Function.info(item, :name) + Map.put(validators, to_string(key), %{"function" => item, "group" => group}) + end) + end + end) + end + + defp add_prefix(groups, nil), do: groups + + defp add_prefix(groups, prefix) do + %{"next" => %{String.trim_trailing(prefix, "-") => groups}, "validators" => %{}} + end +end diff --git a/lib/utils/merge/config.ex b/lib/utils/merge/config.ex new file mode 100644 index 0000000..db45d89 --- /dev/null +++ b/lib/utils/merge/config.ex @@ -0,0 +1,686 @@ +defmodule SaladUI.Merge.Config do + @moduledoc false + import SaladUI.Merge.Validator + + @theme_groups ~w(colors spacing blur brightness borderColor borderRadius borderSpacing borderWidth contrast grayscale hueRotate invert gap gradientColorStops gradientColorStopPositions inset margin opacity padding saturate scale sepia skew space translate) + + def config do + prefix = Application.get_env(:turboprop, :prefix) + theme = Application.get_env(:turboprop, :theme, default_theme()) + + Map.merge(%{prefix: prefix, theme: theme}, settings()) + end + + defp default_theme do + %{ + colors: [&any?/1], + spacing: [&length?/1, &arbitrary_length?/1], + blur: ["none", "", &tshirt_size?/1, &arbitrary_value?/1], + brightness: number(), + borderColor: [&from_theme(&1, "colors")], + borderRadius: ["none", "", "full", &tshirt_size?/1, &arbitrary_value?/1], + borderSpacing: spacing_with_arbitrary(), + borderWidth: length_with_empty_and_arbitrary(), + contrast: number(), + grayscale: zero_and_empty(), + hueRotate: number_and_arbitrary(), + invert: zero_and_empty(), + gap: spacing_with_arbitrary(), + gradientColorStops: [&from_theme(&1, "colors")], + gradientColorStopPositions: [&percent?/1, &arbitrary_length?/1], + inset: spacing_with_auto_and_arbitrary(), + margin: spacing_with_auto_and_arbitrary(), + opacity: number(), + padding: spacing_with_arbitrary(), + saturate: number(), + scale: number(), + sepia: zero_and_empty(), + skew: number_and_arbitrary(), + space: spacing_with_arbitrary(), + translate: spacing_with_arbitrary() + } + end + + defp settings do + %{ + groups: %{ + "aspect" => %{"aspect" => ["auto", "square", "video", &arbitrary_value?/1]}, + "container" => ["container"], + "columns" => %{"columns" => [&tshirt_size?/1]}, + "break-after" => %{"break-after" => breaks()}, + "break-before" => %{"break-before" => breaks()}, + "break-inside" => %{"break-inside" => ["auto", "avoid", "avoid-page", "avoid-column"]}, + "box-decoration" => %{"box-decoration" => ["slice", "clone"]}, + "box" => %{"box" => ["border", "content"]}, + "display" => [ + "block", + "inline-block", + "inline", + "flex", + "inline-flex", + "table", + "inline-table", + "table-caption", + "table-cell", + "table-column", + "table-column-group", + "table-footer-group", + "table-header-group", + "table-row-group", + "table-row", + "flow-root", + "grid", + "inline-grid", + "contents", + "list-item", + "hidden" + ], + "float" => %{"float" => ["right", "left", "none", "start", "end"]}, + "clear" => %{"clear" => ["left", "right", "both", "none", "start", "end"]}, + "isolation" => ["isolate", "isolation-auto"], + "object-fit" => %{"object" => ["contain", "cover", "fill", "none", "scale-down"]}, + "object-position" => %{"object" => [positions(), &arbitrary_value?/1]}, + "overflow" => %{"overflow" => overflow()}, + "overflow-x" => %{"overflow-x" => overflow()}, + "overflow-y" => %{"overflow-y" => overflow()}, + "overscroll" => %{"overscroll" => overscroll()}, + "overscroll-x" => %{"overscroll-x" => overscroll()}, + "overscroll-y" => %{"overscroll-y" => overscroll()}, + "position" => ["static", "fixed", "absolute", "relative", "sticky"], + "inset" => %{"inset" => [&from_theme(&1, "inset")]}, + "inset-x" => %{"inset-x" => [&from_theme(&1, "inset")]}, + "inset-y" => %{"inset-y" => [&from_theme(&1, "inset")]}, + "start" => %{"start" => [&from_theme(&1, "inset")]}, + "end" => %{"end" => [&from_theme(&1, "inset")]}, + "top" => %{"top" => [&from_theme(&1, "inset")]}, + "right" => %{"right" => [&from_theme(&1, "inset")]}, + "bottom" => %{"bottom" => [&from_theme(&1, "inset")]}, + "left" => %{"left" => [&from_theme(&1, "inset")]}, + "visibility" => ["visible", "invisible", "collapse"], + "z" => %{"z" => ["auto", &integer?/1, &arbitrary_value?/1]}, + "basis" => %{"basis" => spacing_with_auto_and_arbitrary()}, + "flex-direction" => %{"flex" => ["row", "row-reverse", "col", "col-reverse"]}, + "flex-wrap" => %{"flex" => ["wrap", "wrap-reverse", "nowrap"]}, + "flex" => %{"flex" => ["1", "auto", "initial", "none", &arbitrary_value?/1]}, + "grow" => %{"grow" => zero_and_empty()}, + "shrink" => %{"shrink" => zero_and_empty()}, + "order" => %{"order" => ["first", "last", "none", &integer?/1, &arbitrary_value?/1]}, + "grid-cols" => %{"grid-cols" => [&any?/1]}, + "col-start-end" => %{"col" => ["auto", %{"span" => ["full", &integer?/1, &arbitrary_value?/1]}, &arbitrary_value?/1]}, + "col-start" => %{"col-start" => number_with_auto_and_arbitrary()}, + "col-end" => %{"col-end" => number_with_auto_and_arbitrary()}, + "grid-rows" => %{"grid-rows" => [&any?/1]}, + "row-start-end" => %{"row" => ["auto", %{"span" => ["full", &integer?/1, &arbitrary_value?/1]}, &arbitrary_value?/1]}, + "row-start" => %{"row-start" => number_with_auto_and_arbitrary()}, + "row-end" => %{"row-end" => number_with_auto_and_arbitrary()}, + "grid-flow" => %{"grid-flow" => ["row", "col", "dense", "row-dense", "col-dense"]}, + "auto-cols" => %{"auto-cols" => ["auto", "min", "max", "fr", &arbitrary_value?/1]}, + "auto-rows" => %{"auto-rows" => ["auto", "min", "max", "fr", &arbitrary_value?/1]}, + "gap" => %{"gap" => [&from_theme(&1, "gap")]}, + "gap-x" => %{"gap-x" => [&from_theme(&1, "gap")]}, + "gap-y" => %{"gap-y" => [&from_theme(&1, "gap")]}, + "justify-content" => %{"justify" => ["normal", align()]}, + "justify-items" => %{"justify-items" => ["start", "end", "center", "stretch"]}, + "justify-self" => %{"justify-self" => ["auto", "start", "end", "center", "stretch"]}, + "align-content" => %{"content" => ["normal", align(), "baseline"]}, + "align-items" => %{"items" => ["start", "end", "center", "baseline", "stretch"]}, + "align-self" => %{"self" => ["auto", "start", "end", "center", "stretch", "baseline"]}, + "place-content" => %{"place-content" => [align(), "baseline"]}, + "place-items" => %{"place-items" => ["start", "end", "center", "baseline", "stretch"]}, + "place-self" => %{"place-self" => ["auto", "start", "end", "center", "stretch"]}, + "p" => %{"p" => [&from_theme(&1, "padding")]}, + "px" => %{"px" => [&from_theme(&1, "padding")]}, + "py" => %{"py" => [&from_theme(&1, "padding")]}, + "ps" => %{"ps" => [&from_theme(&1, "padding")]}, + "pe" => %{"pe" => [&from_theme(&1, "padding")]}, + "pt" => %{"pt" => [&from_theme(&1, "padding")]}, + "pr" => %{"pr" => [&from_theme(&1, "padding")]}, + "pb" => %{"pb" => [&from_theme(&1, "padding")]}, + "pl" => %{"pl" => [&from_theme(&1, "padding")]}, + "m" => %{"m" => [&from_theme(&1, "margin")]}, + "mx" => %{"mx" => [&from_theme(&1, "margin")]}, + "my" => %{"my" => [&from_theme(&1, "margin")]}, + "ms" => %{"ms" => [&from_theme(&1, "margin")]}, + "me" => %{"me" => [&from_theme(&1, "margin")]}, + "mt" => %{"mt" => [&from_theme(&1, "margin")]}, + "mr" => %{"mr" => [&from_theme(&1, "margin")]}, + "mb" => %{"mb" => [&from_theme(&1, "margin")]}, + "ml" => %{"ml" => [&from_theme(&1, "margin")]}, + "space-x" => %{"space-x" => [&from_theme(&1, "space")]}, + "space-x-reverse" => ["space-x-reverse"], + "space-y" => %{"space-y" => [&from_theme(&1, "space")]}, + "space-y-reverse" => ["space-y-reverse"], + "w" => %{"w" => ["auto", "min", "max", "fit", "svw", "lvw", "dvw", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "min-w" => %{"min-w" => ["min", "max", "fit", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "max-w" => %{ + "max-w" => [ + "none", + "full", + "min", + "max", + "fit", + "prose", + &tshirt_size?/1, + &arbitrary_value?/1, + &from_theme(&1, "spacing"), + %{"screen" => [&tshirt_size?/1]} + ] + }, + "h" => %{ + "h" => [ + "auto", + "min", + "max", + "fit", + "svh", + "lvh", + "dvh", + &from_theme(&1, "spacing"), + &arbitrary_value?/1 + ] + }, + "min-h" => %{"min-h" => ["min", "max", "fit", "svh", "lvh", "dvh", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "max-h" => %{"max-h" => ["min", "max", "fit", "svh", "lvh", "dvh", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "size" => %{"size" => ["auto", "min", "max", "fit", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "font-size" => %{"text" => ["base", &tshirt_size?/1, &arbitrary_length?/1]}, + "font-smoothing" => ["antialiased", "subpixel-antialiased"], + "font-style" => ["italic", "not-italic"], + "font-weight" => %{ + "font" => [ + "thin", + "extralight", + "light", + "normal", + "medium", + "semibold", + "bold", + "extrabold", + "black", + &arbitrary_number?/1 + ] + }, + "font-family" => %{"font" => [&any?/1]}, + "fvn-normal" => ["normal-nums"], + "fvn-ordinal" => ["ordinal"], + "fvn-slashed-zero" => ["slashed-zero"], + "fvn-figure" => ["lining-nums", "oldstyle-nums"], + "fvn-spacing" => ["proportional-nums", "tabular-nums"], + "fvn-fraction" => ["diagonal-fractions", "stacked-fractions"], + "tracking" => %{ + "tracking" => [ + "tighter", + "tight", + "normal", + "wide", + "wider", + "widest", + &arbitrary_value?/1 + ] + }, + "line-clamp" => %{"line-clamp" => ["none", &number?/1, &arbitrary_number?/1]}, + "leading" => %{ + "leading" => [ + "none", + "tight", + "snug", + "normal", + "relaxed", + "loose", + &length?/1, + &arbitrary_value?/1 + ] + }, + "list-image" => %{"list-image" => ["none", &arbitrary_value?/1]}, + "list-style-type" => %{"list" => ["none", "disc", "decimal", &arbitrary_value?/1]}, + "list-style-position" => %{"list" => ["inside", "outside"]}, + "placeholder-color" => %{"placeholder" => [&from_theme(&1, "colors")]}, + "placeholder-opacity" => %{"placeholder-opacity" => [&from_theme(&1, "opacity")]}, + "text-alignment" => %{"text" => ["left", "center", "right", "justify", "start", "end"]}, + "text-color" => %{"text" => [&from_theme(&1, "colors")]}, + "text-opacity" => %{"text-opacity" => [&from_theme(&1, "opacity")]}, + "text-decoration" => ["underline", "overline", "line-through", "no-underline"], + "text-decoration-style" => %{"decoration" => [line_styles(), "wavy"]}, + "text-decoration-thickness" => %{"decoration" => ["auto", "from-font", &length?/1, &arbitrary_length?/1]}, + "underline-offset" => %{"underline-offset" => ["auto", &length?/1, &arbitrary_value?/1]}, + "text-decoration-color" => %{"decoration" => [&from_theme(&1, "colors")]}, + "text-transform" => ["uppercase", "lowercase", "capitalize", "normal-case"], + "text-overflow" => ["truncate", "text-ellipsis", "text-clip"], + "text-wrap" => %{"text" => ["wrap", "nowrap", "balance", "pretty"]}, + "indent" => %{"indent" => spacing_with_arbitrary()}, + "vertical-align" => %{ + "align" => [ + "baseline", + "top", + "middle", + "bottom", + "text-top", + "text-bottom", + "sub", + "super", + &arbitrary_value?/1 + ] + }, + "whitespace" => %{"whitespace" => ["normal", "nowrap", "pre", "pre-line", "pre-wrap", "break-spaces"]}, + "break" => %{"break" => ["normal", "words", "all", "keep"]}, + "hyphens" => %{"hyphens" => ["none", "manual", "auto"]}, + "content" => %{"content" => ["none", &arbitrary_value?/1]}, + "bg-attachment" => %{"bg" => ["fixed", "local", "scroll"]}, + "bg-clip" => %{"bg-clip" => ["border", "padding", "content", "text"]}, + "bg-opacity" => %{"bg-opacity" => [&from_theme(&1, "opacity")]}, + "bg-origin" => %{"bg-origin" => ["border", "padding", "content"]}, + "bg-position" => %{"bg" => [positions(), &arbitrary_position?/1]}, + "bg-repeat" => %{"bg" => ["no-repeat", %{"repeat" => ["", "x", "y", "round", "space"]}]}, + "bg-size" => %{"bg" => ["auto", "cover", "contain", &arbitrary_size?/1]}, + "bg-image" => %{ + "bg" => [ + "none", + %{"gradient-to" => ["t", "tr", "r", "br", "b", "bl", "l", "tl"]}, + &arbitrary_image?/1 + ] + }, + "bg-color" => %{"bg" => [&from_theme(&1, "colors")]}, + "gradient-from-pos" => %{"from" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-via-pos" => %{"via" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-to-pos" => %{"to" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-from" => %{"from" => [&from_theme(&1, "gradientColorStops")]}, + "gradient-via" => %{"via" => [&from_theme(&1, "gradientColorStops")]}, + "gradient-to" => %{"to" => [&from_theme(&1, "gradientColorStops")]}, + "rounded" => %{"rounded" => [&from_theme(&1, "borderRadius")]}, + "rounded-s" => %{"rounded-s" => [&from_theme(&1, "borderRadius")]}, + "rounded-e" => %{"rounded-e" => [&from_theme(&1, "borderRadius")]}, + "rounded-t" => %{"rounded-t" => [&from_theme(&1, "borderRadius")]}, + "rounded-r" => %{"rounded-r" => [&from_theme(&1, "borderRadius")]}, + "rounded-b" => %{"rounded-b" => [&from_theme(&1, "borderRadius")]}, + "rounded-l" => %{"rounded-l" => [&from_theme(&1, "borderRadius")]}, + "rounded-ss" => %{"rounded-ss" => [&from_theme(&1, "borderRadius")]}, + "rounded-se" => %{"rounded-se" => [&from_theme(&1, "borderRadius")]}, + "rounded-ee" => %{"rounded-ee" => [&from_theme(&1, "borderRadius")]}, + "rounded-es" => %{"rounded-es" => [&from_theme(&1, "borderRadius")]}, + "rounded-tl" => %{"rounded-tl" => [&from_theme(&1, "borderRadius")]}, + "rounded-tr" => %{"rounded-tr" => [&from_theme(&1, "borderRadius")]}, + "rounded-br" => %{"rounded-br" => [&from_theme(&1, "borderRadius")]}, + "rounded-bl" => %{"rounded-bl" => [&from_theme(&1, "borderRadius")]}, + "border-w" => %{"border" => [&from_theme(&1, "borderWidth")]}, + "border-w-x" => %{"border-x" => [&from_theme(&1, "borderWidth")]}, + "border-w-y" => %{"border-y" => [&from_theme(&1, "borderWidth")]}, + "border-w-s" => %{"border-s" => [&from_theme(&1, "borderWidth")]}, + "border-w-e" => %{"border-e" => [&from_theme(&1, "borderWidth")]}, + "border-w-t" => %{"border-t" => [&from_theme(&1, "borderWidth")]}, + "border-w-r" => %{"border-r" => [&from_theme(&1, "borderWidth")]}, + "border-w-b" => %{"border-b" => [&from_theme(&1, "borderWidth")]}, + "border-w-l" => %{"border-l" => [&from_theme(&1, "borderWidth")]}, + "border-opacity" => %{"border-opacity" => [&from_theme(&1, "opacity")]}, + "border-style" => %{"border" => [line_styles(), "hidden"]}, + "divide-x" => %{"divide-x" => [&from_theme(&1, "borderWidth")]}, + "divide-x-reverse" => ["divide-x-reverse"], + "divide-y" => %{"divide-y" => [&from_theme(&1, "borderWidth")]}, + "divide-y-reverse" => ["divide-y-reverse"], + "divide-opacity" => %{"divide-opacity" => [&from_theme(&1, "opacity")]}, + "divide-style" => %{"divide" => line_styles()}, + "border-color" => %{"border" => [&from_theme(&1, "borderColor")]}, + "border-color-x" => %{"border-x" => [&from_theme(&1, "borderColor")]}, + "border-color-y" => %{"border-y" => [&from_theme(&1, "borderColor")]}, + "border-color-t" => %{"border-t" => [&from_theme(&1, "borderColor")]}, + "border-color-r" => %{"border-r" => [&from_theme(&1, "borderColor")]}, + "border-color-b" => %{"border-b" => [&from_theme(&1, "borderColor")]}, + "border-color-l" => %{"border-l" => [&from_theme(&1, "borderColor")]}, + "divide-color" => %{"divide" => [&from_theme(&1, "borderColor")]}, + "outline-style" => %{"outline" => ["", line_styles()]}, + "outline-offset" => %{"outline-offset" => [&length?/1, &arbitrary_value?/1]}, + "outline-w" => %{"outline" => [&length?/1, &arbitrary_length?/1]}, + "outline-color" => %{"outline" => [&from_theme(&1, "colors")]}, + "ring-w" => %{"ring" => length_with_empty_and_arbitrary()}, + "ring-w-inset" => ["ring-inset"], + "ring-color" => %{"ring" => [&from_theme(&1, "colors")]}, + "ring-opacity" => %{"ring-opacity" => [&from_theme(&1, "opacity")]}, + "ring-offset-w" => %{"ring-offset" => [&length?/1, &arbitrary_length?/1]}, + "ring-offset-color" => %{"ring-offset" => [&from_theme(&1, "colors")]}, + "shadow" => %{"shadow" => ["", "inner", "none", &tshirt_size?/1, &arbitrary_shadow?/1]}, + "shadow-color" => %{"shadow" => [&any?/1]}, + "opacity" => %{"opacity" => [&from_theme(&1, "opacity")]}, + "mix-blend" => %{"mix-blend" => [blend_modes(), "plus-lighter", "plus-darker"]}, + "bg-blend" => %{"bg-blend" => blend_modes()}, + "filter" => %{"filter" => ["", "none"]}, + "blur" => %{"blur" => [&from_theme(&1, "blur")]}, + "brightness" => %{"brightness" => [&from_theme(&1, "brightness")]}, + "contrast" => %{"contrast" => [&from_theme(&1, "contrast")]}, + "drop-shadow" => %{"drop-shadow" => ["", "none", &tshirt_size?/1, &arbitrary_value?/1]}, + "grayscale" => %{"grayscale" => [&from_theme(&1, "grayscale")]}, + "hue-rotate" => %{"hue-rotate" => [&from_theme(&1, "hueRotate")]}, + "invert" => %{"invert" => [&from_theme(&1, "invert")]}, + "saturate" => %{"saturate" => [&from_theme(&1, "saturate")]}, + "sepia" => %{"sepia" => [&from_theme(&1, "sepia")]}, + "backdrop-filter" => %{"backdrop-filter" => ["", "none"]}, + "backdrop-blur" => %{"backdrop-blur" => [&from_theme(&1, "blur")]}, + "backdrop-brightness" => %{"backdrop-brightness" => [&from_theme(&1, "brightness")]}, + "backdrop-contrast" => %{"backdrop-contrast" => [&from_theme(&1, "contrast")]}, + "backdrop-grayscale" => %{"backdrop-grayscale" => [&from_theme(&1, "grayscale")]}, + "backdrop-hue-rotate" => %{"backdrop-hue-rotate" => [&from_theme(&1, "hueRotate")]}, + "backdrop-invert" => %{"backdrop-invert" => [&from_theme(&1, "invert")]}, + "backdrop-opacity" => %{"backdrop-opacity" => [&from_theme(&1, "opacity")]}, + "backdrop-saturate" => %{"backdrop-saturate" => [&from_theme(&1, "saturate")]}, + "backdrop-sepia" => %{"backdrop-sepia" => [&from_theme(&1, "sepia")]}, + "border-collapse" => %{"border" => ["collapse", "separate"]}, + "border-spacing" => %{"border-spacing" => [&from_theme(&1, "borderSpacing")]}, + "border-spacing-x" => %{"border-spacing-x" => [&from_theme(&1, "borderSpacing")]}, + "border-spacing-y" => %{"border-spacing-y" => [&from_theme(&1, "borderSpacing")]}, + "table-layout" => %{"table" => ["auto", "fixed"]}, + "caption" => %{"caption" => ["top", "bottom"]}, + "transition" => %{ + "transition" => [ + "none", + "all", + "", + "colors", + "opacity", + "shadow", + "transform", + &arbitrary_value?/1 + ] + }, + "duration" => %{"duration" => number_and_arbitrary()}, + "ease" => %{"ease" => ["linear", "in", "out", "in-out", &arbitrary_value?/1]}, + "delay" => %{"delay" => number_and_arbitrary()}, + "animate" => %{"animate" => ["none", "spin", "ping", "pulse", "bounce", &arbitrary_value?/1]}, + "transform" => %{"transform" => ["", "gpu", "none"]}, + "scale" => %{"scale" => [&from_theme(&1, "scale")]}, + "scale-x" => %{"scale-x" => [&from_theme(&1, "scale")]}, + "scale-y" => %{"scale-y" => [&from_theme(&1, "scale")]}, + "rotate" => %{"rotate" => [&integer?/1, &arbitrary_value?/1]}, + "translate-x" => %{"translate-x" => [&from_theme(&1, "translate")]}, + "translate-y" => %{"translate-y" => [&from_theme(&1, "translate")]}, + "skew-x" => %{"skew-x" => [&from_theme(&1, "skew")]}, + "skew-y" => %{"skew-y" => [&from_theme(&1, "skew")]}, + "transform-origin" => %{ + "origin" => [ + "center", + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", + "top-left", + &arbitrary_value?/1 + ] + }, + "accent" => %{"accent" => ["auto", &from_theme(&1, "colors")]}, + "appearance" => %{"appearance" => ["none", "auto"]}, + "cursor" => %{ + "cursor" => [ + "auto", + "default", + "pointer", + "wait", + "text", + "move", + "help", + "not-allowed", + "none", + "context-menu", + "progress", + "cell", + "crosshair", + "vertical-text", + "alias", + "copy", + "no-drop", + "grab", + "grabbing", + "all-scroll", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", + &arbitrary_value?/1 + ] + }, + "caret-color" => %{"caret" => [&from_theme(&1, "colors")]}, + "pointer-events" => %{"pointer-events" => ["none", "auto"]}, + "resize" => %{"resize" => ["none", "y", "x", ""]}, + "scroll-behavior" => %{"scroll" => ["auto", "smooth"]}, + "scroll-m" => %{"scroll-m" => spacing_with_arbitrary()}, + "scroll-mx" => %{"scroll-mx" => spacing_with_arbitrary()}, + "scroll-my" => %{"scroll-my" => spacing_with_arbitrary()}, + "scroll-ms" => %{"scroll-ms" => spacing_with_arbitrary()}, + "scroll-me" => %{"scroll-me" => spacing_with_arbitrary()}, + "scroll-mt" => %{"scroll-mt" => spacing_with_arbitrary()}, + "scroll-mr" => %{"scroll-mr" => spacing_with_arbitrary()}, + "scroll-mb" => %{"scroll-mb" => spacing_with_arbitrary()}, + "scroll-ml" => %{"scroll-ml" => spacing_with_arbitrary()}, + "scroll-p" => %{"scroll-p" => spacing_with_arbitrary()}, + "scroll-px" => %{"scroll-px" => spacing_with_arbitrary()}, + "scroll-py" => %{"scroll-py" => spacing_with_arbitrary()}, + "scroll-ps" => %{"scroll-ps" => spacing_with_arbitrary()}, + "scroll-pe" => %{"scroll-pe" => spacing_with_arbitrary()}, + "scroll-pt" => %{"scroll-pt" => spacing_with_arbitrary()}, + "scroll-pr" => %{"scroll-pr" => spacing_with_arbitrary()}, + "scroll-pb" => %{"scroll-pb" => spacing_with_arbitrary()}, + "scroll-pl" => %{"scroll-pl" => spacing_with_arbitrary()}, + "snap-align" => %{"snap" => ["start", "end", "center", "align-none"]}, + "snap-stop" => %{"snap" => ["normal", "always"]}, + "snap-type" => %{"snap" => ["none", "x", "y", "both"]}, + "snap-strictness" => %{"snap" => ["mandatory", "proximity"]}, + "touch" => %{"touch" => ["auto", "none", "manipulation"]}, + "touch-x" => %{"touch-pan" => ["x", "left", "right"]}, + "touch-y" => %{"touch-pan" => ["y", "up", "down"]}, + "touch-pz" => ["touch-pinch-zoom"], + "select" => %{"select" => ["none", "text", "all", "auto"]}, + "will-change" => %{"will-change" => ["auto", "scroll", "contents", "transform", &arbitrary_value?/1]}, + "fill" => %{"fill" => [&from_theme(&1, "colors"), "none"]}, + "stroke-w" => %{"stroke" => [&length?/1, &arbitrary_length?/1, &arbitrary_number?/1]}, + "stroke" => %{"stroke" => [&from_theme(&1, "colors"), "none"]}, + "sr" => ["sr-only", "not-sr-only"], + "forced-color-adjust" => %{"forced-color-adjust" => ["auto", "none"]} + }, + conflicting_groups: %{ + "overflow" => ["overflow-x", "overflow-y"], + "overscroll" => ["overscroll-x", "overscroll-y"], + "inset" => ["inset-x", "inset-y", "start", "end", "top", "right", "bottom", "left"], + "inset-x" => ["right", "left"], + "inset-y" => ["top", "bottom"], + "flex" => ["basis", "grow", "shrink"], + "gap" => ["gap-x", "gap-y"], + "p" => ["px", "py", "ps", "pe", "pt", "pr", "pb", "pl"], + "px" => ["pr", "pl"], + "py" => ["pt", "pb"], + "m" => ["mx", "my", "ms", "me", "mt", "mr", "mb", "ml"], + "mx" => ["mr", "ml"], + "my" => ["mt", "mb"], + "size" => ["w", "h"], + "font-size" => ["leading"], + "fvn-normal" => [ + "fvn-ordinal", + "fvn-slashed-zero", + "fvn-figure", + "fvn-spacing", + "fvn-fraction" + ], + "fvn-ordinal" => ["fvn-normal"], + "fvn-slashed-zero" => ["fvn-normal"], + "fvn-figure" => ["fvn-normal"], + "fvn-spacing" => ["fvn-normal"], + "fvn-fraction" => ["fvn-normal"], + "line-clamp" => ["display", "overflow"], + "rounded" => [ + "rounded-s", + "rounded-e", + "rounded-t", + "rounded-r", + "rounded-b", + "rounded-l", + "rounded-ss", + "rounded-se", + "rounded-ee", + "rounded-es", + "rounded-tl", + "rounded-tr", + "rounded-br", + "rounded-bl" + ], + "rounded-s" => ["rounded-ss", "rounded-es"], + "rounded-e" => ["rounded-se", "rounded-ee"], + "rounded-t" => ["rounded-tl", "rounded-tr"], + "rounded-r" => ["rounded-tr", "rounded-br"], + "rounded-b" => ["rounded-br", "rounded-bl"], + "rounded-l" => ["rounded-tl", "rounded-bl"], + "border-spacing" => ["border-spacing-x", "border-spacing-y"], + "border-w" => [ + "border-w-s", + "border-w-e", + "border-w-t", + "border-w-r", + "border-w-b", + "border-w-l" + ], + "border-w-x" => ["border-w-r", "border-w-l"], + "border-w-y" => ["border-w-t", "border-w-b"], + "border-color" => ["border-color-t", "border-color-r", "border-color-b", "border-color-l"], + "border-color-x" => ["border-color-r", "border-color-l"], + "border-color-y" => ["border-color-t", "border-color-b"], + "scroll-m" => [ + "scroll-mx", + "scroll-my", + "scroll-ms", + "scroll-me", + "scroll-mt", + "scroll-mr", + "scroll-mb", + "scroll-ml" + ], + "scroll-mx" => ["scroll-mr", "scroll-ml"], + "scroll-my" => ["scroll-mt", "scroll-mb"], + "scroll-p" => [ + "scroll-px", + "scroll-py", + "scroll-ps", + "scroll-pe", + "scroll-pt", + "scroll-pr", + "scroll-pb", + "scroll-pl" + ], + "scroll-px" => ["scroll-pr", "scroll-pl"], + "scroll-py" => ["scroll-pt", "scroll-pb"], + "touch" => ["touch-x", "touch-y", "touch-pz"], + "touch-x" => ["touch"], + "touch-y" => ["touch"], + "touch-pz" => ["touch"] + }, + conflicting_group_modifiers: %{ + "font-size" => ["leading"] + } + } + end + + for group <- @theme_groups do + defp from_theme(theme, unquote(group)) do + theme[String.to_existing_atom(unquote(group))] || [] + end + end + + defp overscroll do + ["auto", "contain", "none"] + end + + defp overflow do + ["auto", "hidden", "clip", "visible", "scroll"] + end + + defp spacing_with_auto_and_arbitrary do + ["auto", &arbitrary_value?/1, &from_theme(&1, "spacing")] + end + + defp spacing_with_arbitrary do + [&arbitrary_value?/1, &from_theme(&1, "spacing")] + end + + defp length_with_empty_and_arbitrary do + ["", &length?/1, &arbitrary_length?/1] + end + + defp number_with_auto_and_arbitrary do + ["auto", &number?/1, &arbitrary_value?/1] + end + + defp positions do + [ + "bottom", + "center", + "left", + "left-bottom", + "left-top", + "right", + "right-bottom", + "right-top", + "top" + ] + end + + defp line_styles do + ["solid", "dashed", "dotted", "double", "none"] + end + + defp blend_modes do + [ + "normal", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity" + ] + end + + defp align do + ["start", "end", "center", "between", "around", "evenly", "stretch"] + end + + defp zero_and_empty do + ["", "0", &arbitrary_value?/1] + end + + defp breaks do + [ + "auto", + "avoid", + "all", + "avoid-page", + "page", + "left", + "right", + "column" + ] + end + + defp number do + [&number?/1, &arbitrary_number?/1] + end + + defp number_and_arbitrary do + [&number?/1, &arbitrary_value?/1] + end +end diff --git a/lib/utils/merge/parser.ex b/lib/utils/merge/parser.ex new file mode 100644 index 0000000..a03dba9 --- /dev/null +++ b/lib/utils/merge/parser.ex @@ -0,0 +1,46 @@ +defmodule SaladUI.Merge.Parser do + @moduledoc false + import NimbleParsec + + @chars [?a..?z, ?A..?Z, ?0..?9, ?-, ?_, ?., ?,, ?@, ?{, ?}, ?(, ?), ?>, ?*, ?&, ?', ?%, ?#] + + regular_chars = ascii_string(@chars, min: 1) + + modifier = + [parsec(:arbitrary), regular_chars] + |> choice() + |> times(min: 1) + |> ignore(string(":")) + |> reduce({Enum, :join, [""]}) + |> unwrap_and_tag(:modifier) + |> times(min: 1) + + important = "!" |> string() |> unwrap_and_tag(:important) + + base = + [parsec(:arbitrary), regular_chars] + |> choice() + |> times(min: 1) + |> reduce({Enum, :join, [""]}) + |> unwrap_and_tag(:base) + + postfix = + "/" + |> string() + |> ignore() + |> ascii_string([?a..?z, ?0..?9], min: 1) + |> unwrap_and_tag(:postfix) + + defparsec :arbitrary, + "[" + |> string() + |> concat(times(choice([parsec(:arbitrary), ascii_string(@chars ++ [?:, ?/], min: 1)]), min: 1)) + |> concat(string("]")) + + defparsec :class, + modifier + |> optional() + |> concat(optional(important)) + |> concat(base) + |> concat(optional(postfix)) +end diff --git a/lib/utils/merge/validator.ex b/lib/utils/merge/validator.ex new file mode 100644 index 0000000..24023ca --- /dev/null +++ b/lib/utils/merge/validator.ex @@ -0,0 +1,102 @@ +defmodule SaladUI.Merge.Validator do + @moduledoc false + @arbitrary_value_regex ~r/^\[(?:([a-z-]+):)?(.+)\]$/i + @fraction_regex ~r/^\d+\/\d+$/ + @tshirt_size_regex ~r/^(xs|sm|md|lg|(\d+(\.\d+)?)?xl)$/ + @image_regex ~r/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/ + @color_function_regex ~r/^(rgba?|hsla?|hwb|(ok)?(lab|lch))\(.+\)$/ + @shadow_regex ~r/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/ + @length_unit_regex ~r/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/ + + def arbitrary_value?(value) do + Regex.match?(@arbitrary_value_regex, value) + end + + def integer?(value) do + case Integer.parse(value) do + {_integer, ""} -> true + _else -> false + end + end + + def number?(value) do + # Tailwind allows settings float values without leading zeroes, such as `.01`, which `Float.parse/1` does not support. + value = + if Regex.match?(~r/^\.\d+$/, value) do + "0" <> value + else + value + end + + case Float.parse(value) do + {_float, ""} -> true + _else -> false + end + end + + def length?(value) do + number?(value) or value in ~w(px full screen) or Regex.match?(@fraction_regex, value) + end + + def percent?(value) do + String.ends_with?(value, "%") and number?(String.slice(value, 0..-2//1)) + end + + def tshirt_size?(value) do + Regex.match?(@tshirt_size_regex, value) + end + + def arbitrary_length?(value) do + arbitrary_value?(value, "length", &length_only?/1) + end + + def arbitrary_number?(value) do + arbitrary_value?(value, "number", &number?/1) + end + + def arbitrary_size?(value) do + arbitrary_value?(value, ~w(length size percentage), &never?/1) + end + + def arbitrary_position?(value) do + arbitrary_value?(value, "position", &never?/1) + end + + def arbitrary_image?(value) do + arbitrary_value?(value, ~w(image url), &image?/1) + end + + def image?(value), do: Regex.match?(@image_regex, value) + + def length_only?(value), do: Regex.match?(@length_unit_regex, value) and not Regex.match?(@color_function_regex, value) + + def arbitrary_shadow?(value) do + arbitrary_value?(value, "", &shadow?/1) + end + + def any?, do: true + + def any?(_value), do: true + + def never?(_value), do: false + + def shadow?(value), do: Regex.match?(@shadow_regex, value) + + def arbitrary_value?(value, label, test_value) do + case Regex.run(@arbitrary_value_regex, value) do + [_, label_part, actual_value] -> + if is_binary(label_part) and label_part != "" do + case label do + ^label_part -> true + list when is_list(list) -> label_part in list + _ -> false + end + else + test_value.(actual_value) + end + + _ -> + false + end + end +end diff --git a/mix.exs b/mix.exs index b9d8624..85cf65d 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,8 @@ defmodule SaladUI.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:tails, "~> 0.1"}, + {:nimble_parsec, "~> 1.0"}, + {:deep_merge, "~> 1.0"}, {:phoenix_live_view, "~> 0.20.1"}, {:mix_test_watch, "~> 1.2", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 77cafc2..1b92bb4 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, @@ -21,7 +22,6 @@ "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, - "tails": {:hex, :tails, "0.1.11", "112a62ff06046805c052c3cf6aba6f64fd41e8f82fe8cc1705fb446d04400607", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a908c1e3191610d606f1518eb108b7abe3e91fcc2ebda16b321e917d0d8987dd"}, "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/templates/component.eex b/priv/templates/component.eex index 674d381..0950c1a 100644 --- a/priv/templates/component.eex +++ b/priv/templates/component.eex @@ -16,9 +16,12 @@ defmodule <%= @module_name %>Web.Component do use Phoenix.Component import <%= @module_name %>Web.ComponentHelpers - import Tails, only: [classes: 1] alias Phoenix.LiveView.JS + + defp classes(input) do + SaladUI.Merge.merge(input) + end end end end diff --git a/test/mix/salad.init_test.exs b/test/mix/salad.init_test.exs index 8b9fab7..9ac1dd2 100644 --- a/test/mix/salad.init_test.exs +++ b/test/mix/salad.init_test.exs @@ -48,11 +48,6 @@ defmodule Mix.Tasks.Salad.InitTest do :timer.sleep(100) assert File.exists?(@default_components_path) - - config_content = File.read!("config/config.exs") - assert config_content =~ "config :tails, colors_file:" - assert config_content =~ "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")" - dev_config_content = File.read!("config/dev.exs") assert dev_config_content =~ "config :salad_ui, components_path:" assert dev_config_content =~ "Path.join(File.cwd!(), \"#{@default_components_path}\")" diff --git a/test/salad_ui/accordion_test.exs b/test/salad_ui/accordion_test.exs index 00cd7df..f96c34b 100644 --- a/test/salad_ui/accordion_test.exs +++ b/test/salad_ui/accordion_test.exs @@ -101,8 +101,9 @@ defmodule SaladUI.AccordionTest do assert html =~ ~s(name="my-group") - assert html =~ - ~s(class="flex py-4 transition-all items-center justify-between flex-1 font-medium hover:underline custom-class") + for class <- ~w(flex py-4 transition-all items-center justify-between flex-1 font-medium hover:underline custom-class) do + assert html =~ class + end end end @@ -114,10 +115,11 @@ defmodule SaladUI.AccordionTest do inner_block: [] }) - assert html =~ - ~s(
This is a form message
" + for class <- ~w(text-destructive font-medium text-sm) do + assert html =~ class + end + assert html =~ "This is a form message" end test "It renders an entire form correctly" do @@ -106,11 +111,14 @@ defmodule SaladUI.FormTest do |> rendered_to_string() |> clean_string() + assert html =~ "