defmodule Mix.Tasks.Jetons.Build do @moduledoc """ Generates output from design tokens. ## Config-based config :jetons, css: [ transformer: Jetons.CSS.Transformer, resolver: "tokens.resolver.json", set: [theme: "dark"], output: "priv/static/tokens.css" ] # Or with Token Studio directory config :jetons, css: [ transformer: Jetons.CSS.Transformer, token_studio: "tokens/", set: [theme: "light"], output: "priv/static/tokens.css" ] mix jetons.build # All transformers mix jetons.build css # Specific ## CLI-based mix jetons.build -f tokens.json -o output.css mix jetons.build -f design.resolver.json -o output.css --set theme=dark mix jetons.build -f tokens/ -o output.css --set theme=ACME-Light # Token Studio directory mix jetons.build -m MyApp.Tokens -o output.css Token Studio directories are auto-detected when they contain a `$themes.json` file. """ use Mix.Task alias Jetons.{CSS, Resolver, TokenStudio} alias Jetons.Map, as: JMap @shortdoc "Generate output from design tokens" @switches [ file: [:string, :keep], module: :string, output: :string, set: [:string, :keep], inline: [:string, :keep], selector: :string, transformer: :string ] @aliases [f: :file, m: :module, o: :output, t: :transformer] @impl true def run(args) do {opts, targets} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) cond do opts[:file] || opts[:module] -> run_cli_mode(opts) targets != [] -> Enum.each(targets, &run_config_transformer(&1, opts)) true -> run_all_transformers(opts) end end defp run_all_transformers(cli_opts) do keys = Application.get_all_env(:jetons) if keys == [] do raise Mix.Error, """ No transformers configured. Options: # Config-based (recommended): config :jetons, css: [transformer: MyApp.CSSTransformer, ...] mix jetons.build css # CLI-based (legacy): mix jetons.build -f tokens.json -o output.css """ end Enum.each(keys, fn {key, _config} -> run_config_transformer(key, cli_opts) end) end # ── Config-based mode ──────────────────────────────────────────────── defp run_config_transformer(key, cli_opts) when is_binary(key) do case Enum.find(Application.get_all_env(:jetons), fn {k, _} -> Atom.to_string(k) == key end) do nil -> raise Mix.Error, "Unknown transformer key: #{key}" {atom_key, config} -> run_transformer(atom_key, config, cli_opts) end end defp run_config_transformer(key, cli_opts) do config = Application.get_env(:jetons, key) || raise Mix.Error, "No transformer config for :#{key}. Add config :jetons, :#{key}, [transformer: ...] in config.exs" run_transformer(key, config, cli_opts) end defp run_transformer(key, config, cli_opts) do transformer = Keyword.fetch!(config, :transformer) merged_config = Keyword.merge(config, cli_opts) {:ok, state} = transformer.init(merged_config) with {:ok, raw_config} <- resolve_raw_config(merged_config), {:ok, files} <- transformer.transform(raw_config, merged_config, state) do write_files(files) Mix.shell().info("Generated #{length(files)} file(s) for #{key}") else {:error, reason} -> raise Mix.Error, "Transformer #{key} failed: #{reason}" end end defp resolve_raw_config(config) do cond do config[:token_studio] -> resolve_token_studio(config) config[:resolver] -> resolve_resolver_file(config) true -> {:ok, config[:input] || %{}} end end defp resolve_token_studio(config) do path = Path.expand(config[:token_studio]) inputs = inputs_from_set(config) with {:ok, resolver} <- load_token_studio_resolver(path) do if inputs == %{} do # No --set provided: pass resolver doc for multi-theme output {:ok, %{"$resolver" => resolver, "$base_dir" => path}} else resolve_token_studio_inputs(resolver, inputs, path) end end end defp load_token_studio_resolver(path) do resolver = path |> TokenStudio.load() |> TokenStudio.to_resolver(token_dir: path, output_dir: path) {:ok, resolver} rescue e in [ArgumentError, File.Error] -> {:error, Exception.message(e)} end defp resolve_token_studio_inputs(resolver, inputs, path) do {:ok, Resolver.resolve!(resolver, inputs, base_dir: path)} rescue e in [ArgumentError, File.Error] -> {:error, Exception.message(e)} end defp resolve_resolver_file(config) do path = config[:resolver] base_dir = Path.dirname(Path.expand(path)) with {:ok, raw} <- File.read(path), {:ok, doc} <- Jason.decode(raw) do inputs = inputs_from_set(config) if inputs == %{} do # No --set provided: pass resolver doc for multi-theme output {:ok, %{"$resolver" => doc, "$base_dir" => base_dir}} else {:ok, Resolver.resolve!(doc, inputs, base_dir: base_dir)} end else {:error, %Jason.DecodeError{} = e} -> {:error, Exception.message(e)} {:error, posix} -> {:error, "could not read #{path}: #{posix}"} end end # ── CLI mode (backward compatibility) ─────────────────────────────── defp run_cli_mode(opts) do transformer = resolve_transformer(opts[:transformer]) config = build_cli_config(opts) {:ok, state} = transformer.init(config) raw_config = load_cli_files(opts) case transformer.transform(raw_config, config, state) do {:ok, files} -> write_files(files) Mix.shell().info("Generated #{length(files)} file(s)") {:error, reason} -> raise Mix.Error, "Transform failed: #{reason}" end end defp resolve_transformer(nil), do: CSS.Transformer defp resolve_transformer(name) do Module.safe_concat([name]) end defp build_cli_config(opts) do [] |> put_if(opts[:output], :output) |> put_if(opts[:selector], :selector) |> merge_list_opts(opts, :inline) |> merge_list_opts(opts, :set) end defp put_if(list, nil, _key), do: list defp put_if(list, value, key), do: [{key, value} | list] defp merge_list_opts(list, opts, key) do values = Keyword.get_values(opts, key) if values == [] do list else [{key, values} | list] end end defp load_cli_files(opts) do files = Keyword.get_values(opts, :file) set_values = Keyword.get_values(opts, :set) inputs = parse_set_values(set_values) files |> Enum.map(&load_single_file/1) |> Enum.reduce(%{}, fn {:config, config}, acc -> JMap.deep_merge(acc, config) {:resolver, doc, base_dir}, acc -> if inputs == %{} do # No --set provided: pass resolver doc for multi-theme output # Only works if this is the only file (no merging) %{"$resolver" => doc, "$base_dir" => base_dir} else resolved = Resolver.resolve!(doc, inputs, base_dir: base_dir) JMap.deep_merge(acc, resolved) end end) end defp parse_set_values(set_list) do set_list |> Enum.flat_map(fn set_str -> case String.split(set_str, "=", parts: 2) do [key, value] -> [{key, value}] _ -> [] end end) |> Map.new() end defp load_single_file(path) do expanded = Path.expand(path) cond do File.dir?(expanded) and File.exists?(Path.join(expanded, "$themes.json")) -> resolver = expanded |> TokenStudio.load() |> TokenStudio.to_resolver(token_dir: expanded, output_dir: expanded) {:resolver, resolver, expanded} String.ends_with?(path, ".resolver.json") -> {:resolver, Jason.decode!(File.read!(path)), Path.dirname(expanded)} true -> {:config, Jason.decode!(File.read!(path))} end end # ── Helpers ───────────────────────────────────────────────────────── defp write_files(files) do Enum.each(files, fn {path, content} -> expanded = Path.expand(path) File.mkdir_p!(Path.dirname(expanded)) File.write!(expanded, content) end) end defp inputs_from_set(config) do config |> Keyword.get(:set, []) |> Map.new(fn {k, v} -> {to_string(k), v} end) end end