Current section
Files
Jump to
Current section
Files
lib/mix/tasks/jetons.build.ex
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