defmodule Mix.Tasks.Tailwind.GenerateClasses do @moduledoc """ Mix task for generating, parsing, and referencing all Phoenix UI classes. """ alias Phoenix.UI.Theme import Phoenix.{Component, LiveViewTest} use Mix.Task use Phoenix.UI @impl true def run(_args) do Application.put_env(:phoenix, :json_library, Jason) File.mkdir_p!("lib/phoenix_ui/tailwind") file = File.open!("lib/phoenix/ui/tailwind/generated_classes.ex", [:write]) IO.binwrite(file, """ defmodule Phoenix.UI.Tailwind.GeneratedClasses do @moduledoc \"\"\" Referential Phoenix.UI classes for JIT compilation: \"\"\" # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength """) IO.binwrite(file, "#") [ # Accordion { &accordion/1, [ header: [[%{inner_block: fn _, _ -> "header" end}]], id: ["accordion"], open: [true, false], panel: [[%{inner_block: fn _, _ -> "panel" end}]] ] }, # Alert { &alert/1, [ action: [["phx-click": "lv:clear-flash"]], severity: ["error", "info", "success", "warning"], variant: ["filled", "outlined", "standard"] ] }, # AvatarGroup { &avatar_group/1, [ avatar: [[%{inner_block: nil}]], color: Theme.colors(), inner_block: [nil, []], size: ["xs", "sm", "md", "lg", "xl"], spacing: ["xs", "sm", "md", "lg", "xl"], variant: ["circular", "rounded", "square"] ] }, # Avatar { &avatar/1, [ inner_block: [[]], color: Theme.colors(), border: [true, false], size: ["xs", "sm", "md", "lg", "xl"] ++ range(0.25, 20, 0.25), variant: ["circular", "rounded", "square"] ] }, # Backdrop { &backdrop/1, [ invisible: [true, false], open: [true, false], transition_duration: Theme.transition_durations() ] }, # Badge { &badge/1, [ color: Theme.colors(), content: [""], position: [ "bottom_end", "bottom_start", "bottom", "left_end", "left_start", "left", "right_end", "right_start", "right", "top_end", "top_start", "top" ], variant: ["dot", "standard"] ] }, # Breadcrumb { &breadcrumbs/1, [ a: [ [ %{inner_block: fn _, _ -> "Phoenix UI" end}, %{inner_block: fn _, _ -> "Components" end} ] ] ] }, # Button { &button/1, [ color: Theme.colors(), disabled: [true, false], size: ["xs", "sm", "md", "lg", "xl"], square: [true, false], variant: ["contained", "icon", "outlined", "text"] ] }, # Card { &card/1, [ elevation: [0, 1, 2, 3, 4, 5], square: [true, false], variant: ["elevated", "outlined"] ] }, # Card Header {&card_header/1, []}, # Card Media {&card_media/1, [src: [""]]}, # Card Content {&card_content/1, []}, # Card Actions {&card_action/1, []}, # Chip { &chip/1, [ color: Theme.colors(), label: ["label"], on_click: ["handle_click"], on_delete: ["handle_delete"], variant: ["filled", "outlined"] ] }, # Collapse { &collapse/1, [ max_size: Enum.map(range(100, 5000, 100), &"#{&1}px"), open: [true, false], orientation: ["horizontal", "vertical"], transition_duration: Theme.transition_durations() ] }, # Container { &container/1, [ max_width: Theme.max_widths(), variant: ["fixed", "fluid"] ] }, # Divider { ÷r/1, [ color: Theme.colors(), variant: ["full_width", "inset", "middle"] ] }, # Drawer { &drawer/1, [ id: ["drawer"], open: [true, false], variant: [:temporary] ] }, # Grid { &grid/1, [ column_spacing: 1..12, columns: 1..12, row_spacing: 1..12, spacing: 1..12 ] }, # Heroicon { &heroicon/1, [ color: Theme.colors(), name: ["academic-cap"], size: ["xs", "sm", "md", "lg", "xl"] ++ range(0.25, 20, 0.25), variant: ["outline", "solid"] ] }, # Link { &link/1, [ color: Theme.colors(), disabled: [false, true] ] }, # List {&list/1, []}, # List Item {&list_item/1, []}, # Modal { &modal/1, [ id: ["modal"], max_width: [:xs, :sm, :md, :lg, :xl], open: [true, false] ] }, # Paper { &paper/1, [ blur: [true, false, "none", "sm", "md", "lg", "xl", "2xl", "3xl"], elevation: [0, 1, 2, 3, 4, 5], square: [true, false], variant: ["elevated", "outlined"] ] }, # Tooltip { &tooltip/1, [ color: Theme.colors(), content: [""], position: [ "bottom_end", "bottom_start", "bottom", "left_end", "left_start", "left", "right_end", "right_start", "right", "top_end", "top_start", "top" ], variant: ["arrow", "simple"] ] }, # Typography { &typography/1, [ align: ["center", "justify", "left", "right"], color: Theme.colors(), variant: ["h1", "h2", "h3", "h4", "p"] ] } ] |> Enum.map(fn {fun, args} -> generate_component_classes(fun, args) end) |> List.flatten() |> Enum.uniq() |> Enum.sort() |> Enum.each(fn class -> IO.binwrite(file, " #{class}") end) IO.binwrite(file, """ end """) File.close(file) Mix.Task.run("format") end defp generate_component_classes(component, attr_permutations) do attr_permutations |> Keyword.put_new(:inner_block, [[]]) |> Enum.map(fn {prop, opts} -> Enum.map(opts, fn opt -> Map.put(%{}, prop, opt) end) end) |> Enum.reduce(nil, &permutation/2) |> Enum.map(&extract_classes(&1, component)) |> List.flatten() |> Enum.uniq() |> Enum.sort() end defp permutation(set1, nil), do: set1 defp permutation(set1, set2) do Enum.reduce(set1, [], fn item1, acc -> Enum.reduce(set2, acc, fn item2, acc -> [Map.merge(item1, item2) | acc] end) end) end defp extract_classes(assigns, component) do html = render_component(component, assigns) ~r/(?<=class=\").*?(?=\")/ |> Regex.scan(html) |> List.flatten() |> Enum.map(&String.split/1) end defp range(first, last, step), do: apply_range([truncate(first)], last, step) defp apply_range([current | _] = acc, last, step) when current < last do apply_range([truncate(current + step) | acc], last, step) end defp apply_range(acc, _last, _step), do: Enum.reverse(acc) defp truncate(val) do truncated = trunc(val) if val - truncated != 0, do: val, else: truncated end end