Jetons

A compile-time design token library for Elixir.

Jetons generates fast, type-safe accessor functions from design token JSON files at compile time. Instead of runtime map lookups, tokens become individual function clauses that use pattern matching for maximum performance.

Features

Installation

Add jetons to your dependencies in mix.exs:

def deps do
[
{:jetons, "~> 0.1.1"},
{:jason, "~> 1.4"} # Required for JSON parsing
]
end

Quick Start

Single Theme

defmodule MyApp.Tokens do
use Jetons,
main: File.read!("tokens.json") |> Jason.decode!()
end
MyApp.Tokens.token("colors.primary") # => "#1B66B3"
MyApp.Tokens.token!("colors.primary") # => "#1B66B3" (raises if not found)
MyApp.Tokens.list_colors() # => [{"colors.primary", "#1B66B3"}, ...]

Multiple Themes

defmodule MyApp.Tokens do
use Jetons,
light: File.read!("tokens/light.json") |> Jason.decode!(),
dark: File.read!("tokens/dark.json") |> Jason.decode!()
end
MyApp.Tokens.token("colors.background") # Uses first theme (light)
MyApp.Tokens.token("colors.background", :dark) # Explicit theme
MyApp.Tokens.themes() # => [:light, :dark]
MyApp.Tokens.list_colors(:dark) # List colors for dark theme

Token Format

Jetons expects tokens in DTCG format: nested maps where leaf nodes have "$value" keys. Keys starting with $ ($type, $description, etc.) are metadata and filtered from the output.

{
"colors": {
"$type": "color",
"brand": {
"primary": { "$value": "#1B66B3" }
},
"grey": {
"500": { "$value": "#676767" }
}
},
"spacing": {
"small": { "$value": "8px" }
}
}

Nested keys become dot-notation paths:

Token References

Tokens can reference other tokens using curly-brace syntax or JSON Pointers. References are resolved transitively, and circular references raise at compile time.

{
"colors": {
"palette": { "red": { "$value": "#FF0000" } },
"brand": { "$value": "{colors.palette.red}" },
"button": { "$value": "#/colors/brand" }
}
}

Both colors.brand and colors.button resolve to "#FF0000".

Structural Inheritance ($extends)

Groups can inherit tokens from a parent group using $extends. Child properties override inherited ones, and $extends chains are resolved transitively.

{
"colors": {
"base": {
"$type": "color",
"primary": { "$value": "#FF0000" }
},
"brand": {
"$extends": "colors.base",
"secondary": { "$value": "#00FF00" }
}
}
}

colors.brand inherits $type and primary from colors.base, and adds its own secondary. Cross-category extends (e.g. "brand.palette" extending "colors.base") is also supported.

Type-Based Transforms

The $type field propagates from parent groups to descendant tokens (a child can override with its own $type). You can then apply transform functions that target specific types:

defmodule MyApp.Tokens do
use Jetons,
transforms: [
{"color", fn v -> String.downcase(v) end},
{"dimension", fn v -> String.replace(v, "px", "rem") end}
],
main: %{
"colors" => %{
"$type" => "color",
"primary" => %{"$value" => "#FF0000"}
},
"spacing" => %{
"$type" => "dimension",
"small" => %{"$value" => "8px"}
}
}
end
MyApp.Tokens.token("colors.primary") # => "#ff0000"
MyApp.Tokens.token("spacing.small") # => "8rem"

Transforms run after reference resolution, so referenced values are transformed correctly. Tokens whose type doesn't match any transform pass through unchanged.

Generated API

Single Theme

FunctionDescription
token/1Get token value by path (returns nil if not found)
token!/1Get token value by path (raises KeyError if not found)
list_<category>/0List all tokens in a category as {path, value} tuples
group_by_path/2Group tokens by path depth into a nested map
get_groups/1Get sorted list of top-level groups for a category
get_in_group/2Get all tokens in a specific group as {key, value} tuples

Multi-Theme

All single-theme functions plus:

FunctionDescription
token/2Get token value with explicit theme parameter
token!/2Get token value with theme (raises if not found)
list_<category>/1List category tokens for a specific theme
themes/0List all available theme names
group_by_path/3Group tokens by path depth for a specific theme
get_groups/2Get top-level groups for a category and theme
get_in_group/3Get tokens in a group for a specific theme

Runtime Functions

These modules are available for runtime use outside of use Jetons:

Jetons.Parser — Pipeline orchestrator

FunctionDescription
from_config/1,2Parse a DTCG config map into {path, value} tuples
from_files/1,2Parse multiple grouped JSON files with cross-group reference resolution
extract_categories/1Extract sorted unique category names from token tuples

Jetons.DTCG — DTCG format operations

FunctionDescription
flatten/2Flatten a nested token map under a given prefix
apply_extends/1Resolve $extends inheritance in a config map
type_map/1Build a %{path => type} map with $type inheritance
descriptions/1Extract $description metadata from a config map
deprecated/1Extract $deprecated metadata from a config map

Jetons.Ref — Token reference expansion

FunctionDescription
expand/1Expand {ref} and #/pointer references in token tuples
ref?/1Check if a string is a brace reference
path/1Extract the dot-path from a brace reference
closest/2Find the closest match to a path using Jaro distance

Jetons.Transformer — Output format behaviour

FunctionDescription
init/1Initialize transformer state from config options
transform/3Transform raw DTCG config to {path, content} file tuples
flatten/1Helper (via use Jetons.Transformer) to flatten config to token list

Jetons.CSS.Transformer — Built-in CSS custom properties transformer (implements Jetons.Transformer)

Jetons.Map — Map utilities

FunctionDescription
deep_merge/2Recursively merge two maps (right side wins)

Build Task

mix jetons.build generates output files from design tokens. It supports a config-based transformer architecture (recommended) and a legacy CLI mode.

Configure transformers in your application config:

config :jetons,
css: [
transformer: Jetons.CSS.Transformer,
resolver: "tokens.resolver.json",
set: [theme: "dark"],
output: "priv/static/tokens.css",
inline: ["color.palette"],
selector: ":root"
]

Then run:

mix jetons.build # Run all configured transformers
mix jetons.build css # Run a specific transformer

CLI flags override config values (e.g. --output, --selector).

Custom Transformers

Implement the Jetons.Transformer behaviour to generate any output format:

defmodule MyApp.IOSTransformer do
use Jetons.Transformer
def init(opts) do
{:ok, %{class_name: opts[:class_name] || "Tokens"}}
end
def transform(raw_config, opts, state) do
tokens = flatten(raw_config)
swift = generate_swift(tokens, state)
{:ok, [{opts[:output] || "Tokens.swift", swift}]}
end
end
config :jetons,
ios: [transformer: MyApp.IOSTransformer, class_name: "DesignTokens", output: "Tokens.swift"]

CLI mode (legacy)

The original CLI interface is still supported for quick one-off generation:

# Single JSON file
mix jetons.build -f tokens.json -o tokens.css
# Multiple files (merged in order)
mix jetons.build -f primitives.json -f semantics.json -o tokens.css
# From a module
mix jetons.build -m MyApp.Tokens -o tokens.css
# Custom transformer
mix jetons.build -f tokens.json -o tokens.css -t MyApp.IOSTransformer

Resolver files

Resolver files (.resolver.json) describe how to compose token files using sets and modifiers. Works in both config and CLI modes:

# config/config.exs
config :jetons, css: [resolver: "design.resolver.json", set: [brand: "acme", theme: "dark"], ...]
mix jetons.build -f design.resolver.json -o tokens.css --set brand=markant
mix jetons.build -f design.resolver.json -o tokens.css --set brand=acme --set theme=dark

When no --set flags are given, all modifier permutations are generated as diff-only CSS blocks:

:root {
--spacing-small: 8px;
--color-bg: #FFFFFF;
--color-text: #000000;
}
.theme-dark {
--color-bg: #000000;
--color-text: #FFFFFF;
}

Custom Modifier Selectors

Modifiers can specify custom CSS selectors and emit blocks for default contexts using $extensions:

{
"modifiers": {
"shade": {
"default": "dark",
"$extensions": {
"dev.jetons.css": {
"selector": ".shade-{context}, .shade-{context} *",
"emitDefault": true
}
},
"contexts": { "dark": [...], "light": [...] }
}
}
}

This produces .shade-dark, .shade-dark * { ... } (full block for the default) and .shade-light, .shade-light * { ... } (diff-only). Without $extensions, selectors default to .{modifier}-{context}.

Category Prefixes

CSS property names come straight from the token path (color.hue.300--color-hue-300). Figma exports each collection flat (hue, accent, …), which would emit --hue-300, --accent. To nest those groups under a category parent, tag the set or modifier that carries them with a dev.jetons.cssprefix — the group name to hoist under:

{
"sets": {
"primitives": {
"$extensions": { "dev.jetons.css": { "prefix": "color" } },
"sources": [{ "$ref": "palette.tokens.json" }]
}
},
"modifiers": {
"hue": {
"$extensions": { "dev.jetons.css": { "prefix": "color" } },
"contexts": { "blue": [...], "red": [...] }
}
}
}

At build time from_resolver/3 hoists every top-level group of each tagged source under color and rewrites the references that point at them — in memory, leaving the source token files in their flat Figma shape. The keys are derived automatically from each tagged source's top-level groups, so you never list them:

source (flat): hue.300 {hue.300} (a reference)
build-time nest: color.hue.300 {color.hue.300}
emitted CSS: --color-hue-300 var(--color-hue-300)

Untagged sets/modifiers are left alone, so groups already shaped as color.* (or other categories like spacing.*) keep their names. For one-off rewriting of the source files themselves, see mix jetons.namespace (with --unnest to reverse).

Composite Utility Classes

Typography ($type: "typography") and surface ($type: "x-surface") composite tokens render as CSS utility classes instead of custom properties:

{
"typography-display": {
"$type": "typography",
"$value": {
"fontFamily": "{font.display}",
"fontSize": "{text.display}",
"fontWeight": 900,
"lineHeight": "{text.display--line-height}",
"letterSpacing": "{text.display--letter-spacing}"
}
}
}

Generates:

.typography-display {
font-family: var(--font-display);
font-size: var(--text-display);
font-weight: 900;
letter-spacing: var(--text-display--letter-spacing);
line-height: var(--text-display--line-height);
}

See the CSS Generation guide for full documentation.

Options

FlagConfig keyDescription
-f / --fileresolver:Token or resolver JSON file (repeatable)
-m / --moduleModule that use Jetons (CLI only)
-o / --outputoutput:Output file path
--setset:Pin a modifier value (key=value, repeatable)
--inlineinline:Resolve token prefixes to literal values instead of var() references
--selectorselector:Override root CSS selector (default :root)
-t / --transformertransformer:Transformer module (CLI default: Jetons.CSS.Transformer)

Token Inspection

Debug and explore tokens with mix jetons.inspect.

List Modifier Permutations

mix jetons.inspect -f design.resolver.json --permutations
Modifiers:
brand: acme (default), markant
theme: light (default), dark
Permutations (4):
brand=acme, theme=light
brand=acme, theme=dark
brand=markant, theme=light
brand=markant, theme=dark

Look Up a Token Across Contexts

mix jetons.inspect -f design.resolver.json --token color.brand.primary
mix jetons.inspect -f design.resolver.json --token color.brand.primary --set brand=markant
Token: color.brand.primary
Type: color
brand=acme, theme=light #1B66B3 (via {color.blue.700})
brand=acme, theme=dark {color.blue.700} (unresolved)
brand=markant, theme=light #E63946 (via {color.red.500})
brand=markant, theme=dark {color.red.500} (unresolved)

Trace Reference Chains

mix jetons.inspect -f design.resolver.json --refs button.primary.color-background.default
button.primary.color-background.default
└─ color.background.brand.default
└─ color.brand.primary
└─ #1B66B3

Diff Between Contexts

# Diff defaults vs a specific context
mix jetons.inspect -f design.resolver.json --diff --set theme=dark
# Diff two explicit contexts
mix jetons.inspect -f design.resolver.json --diff --set brand=acme --vs brand=markant
Diff: brand=acme, theme=light → brand=acme, theme=dark
31 token(s) changed
color.background.default {color.utility.white} → {color.grey.950}
color.background.subtle {color.grey.50} → {color.grey.900}
color.content.default {color.grey.900} → {color.grey.50}

Performance

Jetons generates individual function clauses for each token:

def token("colors.primary"), do: "#1B66B3"
def token("colors.secondary"), do: "#FCE531"
# ... one clause per token
def token(_), do: nil

This means:

License

MIT