defmodule Mix.Tasks.Jetons.Namespace do @moduledoc """ Hoists the `--keys` token groups under `--group` across every `*.tokens.json` under DIR and rewrites the `{ref}`s pointing at them, so the tokens emit `--group-*` CSS properties (`hue.300` -> `color.hue.300` -> `--color-hue-300`). Idempotent; only rewrites files it changes. See `Jetons.Namespace`. ## Usage mix jetons.namespace path/to/tokens --group color --keys accent,hue,hue-t,utility mix jetons.namespace path/to/tokens --group color --keys accent,hue,hue-t,utility --check ## Options * `--group` - parent group to nest under (required) * `--keys` - comma-separated top-level keys to move and re-reference (required) * `--unnest` - reverse: lift `--keys` out of `--group` and strip the prefix * `--check` - report changes without writing; exits non-zero if any are needed ## Example Given `palette.tokens.json` (`--group color --keys hue`): { "hue": { "300": { "$value": "#3b82f6" } } } the task rewrites it in place to: { "color": { "hue": { "300": { "$value": "#3b82f6" } } } } and prints `palette.tokens.json: nested 1 group(s), rewrote refs`. """ use Mix.Task alias Jetons.Namespace @shortdoc "Nest top-level token groups under a parent (e.g. hue -> color.hue)" @switches [group: :string, keys: :string, unnest: :boolean, check: :boolean] @impl true def run(args) do {opts, paths} = OptionParser.parse!(args, strict: @switches) dir = Path.expand(List.first(paths) || ".") group = Keyword.fetch(opts, :group) |> ok_or_raise("--group is required") keys_csv = Keyword.fetch(opts, :keys) |> ok_or_raise("--keys is required") keys = keys_csv |> String.split(",", trim: true) |> Enum.map(&String.trim/1) mode = if Keyword.get(opts, :check, false), do: :check, else: :write direction = if Keyword.get(opts, :unnest, false), do: :unnest, else: :nest config = %{dir: dir, group: group, keys: keys, mode: mode, direction: direction} files = Path.wildcard(Path.join(dir, "**/*.tokens.json")) if files == [] do raise Mix.Error, "No *.tokens.json files found under #{dir}" end {moved, touched} = Enum.reduce(files, {0, 0}, &process(&1, &2, config)) summary(moved, touched, mode) end defp ok_or_raise({:ok, value}, _message), do: value defp ok_or_raise(:error, message), do: raise(Mix.Error, message) defp process(file, {moved, touched}, %{dir: dir, group: group, keys: keys} = config) do decoded = file |> File.read!() |> Jason.decode!() {converted, count} = transform(config.direction, decoded, group, keys) if converted == decoded do {moved, touched} else write_or_report(file, converted, count, dir, config.mode, config.direction) {moved + count, touched + 1} end end defp transform(:nest, map, group, keys), do: Namespace.nest(map, group, keys) defp transform(:unnest, map, group, keys), do: Namespace.unnest(map, group, keys) defp verb(:nest), do: "nest" defp verb(:unnest), do: "lift" defp write_or_report(file, _converted, count, dir, :check, direction) do Mix.shell().info( "#{relative(file, dir)}: #{count} group(s) to #{verb(direction)}, refs to rewrite" ) end defp write_or_report(file, converted, count, dir, :write, direction) do File.write!(file, Jason.encode!(converted, pretty: true) <> "\n") Mix.shell().info( "#{relative(file, dir)}: #{verb(direction)}ed #{count} group(s), rewrote refs" ) end defp relative(file, dir), do: Path.relative_to(file, Path.dirname(dir)) defp summary(moved, touched, :check) do Mix.shell().info("\n#{moved} group(s) across #{touched} file(s) need rewriting") if touched > 0, do: exit({:shutdown, 1}) end defp summary(moved, touched, :write) do Mix.shell().info("\nDone: rewrote #{moved} group(s) across #{touched} file(s)") end end