✨ Resolve tokens with the tokenscript-interpreter package (#12348)

Replace the @penpot/tokenscript npm package (the TypeScript
interpreter) with penpot/tokenscript-interpreter, a Clojure and
ClojureScript interpreter that now lives in its own repository and
compiles into the frontend build.

The frontend adapter (app.main.data.tokenscript) resolves tokens with
it behind the existing :tokenscript flag and maps its results to the
values and errors the workspace uses:

- Token values are validated by the interpreter's token types, passed
  by their DTCG names: `1foo` is not a dimension, a number has no
  unit, an opacity is between 0 and 1. Penpot only adds its own
  limits (font weight names, text decoration keywords).
- Division by zero, incompatible units, unknown functions and
  function arguments out of range get messages of their own, and
  token pills show the message of their error.
- Font family names are quoted before resolving, as in the adapter
  this replaces (#11750): `Red Hat` stays one name.

The token form's value input passed the token name to the tokenscript
resolver in place of the typed value, and no name at all for a new
token. It now resolves the typed value under the form's token name, as
the StyleDictionary path does.

StyleDictionary stays the default until the interpreter is stable.

frontend/deps.edn pins the interpreter by tag and commit, v0.1.0.

Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com>
This commit is contained in:
Florian Schrödl 2026-10-22 17:32:52 +02:00 committed by GitHub
parent ba78ccb8c9
commit 9aa78a536b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 619 additions and 2013 deletions

View File

@ -664,6 +664,14 @@
(when (contains? text-decoration-values normalized-value)
normalized-value)))
(def text-case-values #{"none" "uppercase" "lowercase" "capitalize"})
(defn number-out-of-bounds?
"Whether a resolved numeric token value is outside the safe integer
range."
[n]
(or (>= n sm/max-safe-int) (<= n sm/min-safe-int)))
(def font-weight-aliases
{"100" #{"thin" "hairline"},
"200" #{"ultra light" "extralight" "extraleicht" "extra-light" "ultra-light" "ultralight" "extra light"},

View File

@ -30,6 +30,11 @@
:git/sha "d726717"
:git/url "https://github.com/funcool/rumext.git"}
penpot/tokenscript-interpreter
{:git/tag "v0.1.0"
:git/sha "224547d"
:git/url "https://github.com/penpot/tokenscript-interpreter.git"}
instaparse/instaparse {:mvn/version "1.5.0"}
garden/garden {:git/url "https://github.com/noprompt/garden"
:git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"}

View File

@ -56,7 +56,6 @@
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
"@penpot/svgo": "penpot/svgo#3.3.0",
"@penpot/text-editor": "link:text-editor",
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@playwright/test": "1.63.0",
"@storybook/addon-docs": "10.6.1",

View File

@ -1,3 +0,0 @@
node_modules
*.sublime-workspace
/.yarn

View File

@ -1,2 +0,0 @@
export * from "./schemas.js";
export * from "@tokens-studio/tokenscript-interpreter";

View File

@ -1,13 +0,0 @@
{
"name": "@penpot/tokenscript",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"@tokens-studio/tokenscript-interpreter": "^0.26.0"
}
}

File diff suppressed because one or more lines are too long

View File

@ -210,9 +210,6 @@ importers:
'@penpot/text-editor':
specifier: link:text-editor
version: link:text-editor
'@penpot/tokenscript':
specifier: link:packages/tokenscript
version: link:packages/tokenscript
'@penpot/ua-parser':
specifier: penpot/ua-parser#1.0.0
version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4
@ -445,12 +442,6 @@ importers:
packages/mousetrap: {}
packages/tokenscript:
dependencies:
'@tokens-studio/tokenscript-interpreter':
specifier: ^0.26.0
version: 0.26.0
packages/ui:
dependencies:
react:
@ -547,12 +538,6 @@ packages:
'@adobe/css-tools@4.5.0':
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
'@ark/schema@0.56.6':
resolution: {integrity: sha512-OmLv80BCF2ib+zjecrXDosip/CSnZSYBGjblhqWYZWFOzdLQka6QymHToE9u7Z/HZW2KA8Hwc8MCNXoI3uGSIA==}
'@ark/util@0.56.6':
resolution: {integrity: sha512-mHqq0f0HyDd9YadpFl25w0QECAP7d8HGIUpEC85duD+L+xr4i/CwfOid/Z7zJFFF/47oLt6+pZB9RbqWpBYTYg==}
'@asamuzakjp/css-color@7.1.3':
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
@ -1902,11 +1887,6 @@ packages:
peerDependencies:
style-dictionary: ^5.0.0
'@tokens-studio/tokenscript-interpreter@0.26.0':
resolution: {integrity: sha512-dGjvUJnXRspWYp98FZw43l4cN+0ey/cF5sEJjL3coKc5C7DY7MsKgkmOONizmaZqf13GUIzklTEas3gt3jvrOQ==}
engines: {node: '>=16.0.0'}
hasBin: true
'@tokens-studio/types@0.5.2':
resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==}
@ -2134,12 +2114,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
arkregex@0.0.12:
resolution: {integrity: sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==}
arktype@2.2.7:
resolution: {integrity: sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==}
array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@ -3873,9 +3847,6 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
picocolors@0.2.1:
resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
@ -4174,10 +4145,6 @@ packages:
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
engines: {node: '>= 20.19.0'}
readline-sync@1.4.10:
resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
engines: {node: '>= 0.8.0'}
recast@0.23.21:
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
engines: {node: '>= 4'}
@ -5203,10 +5170,6 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yauzl@3.4.0:
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
engines: {node: '>=12'}
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
@ -5215,12 +5178,6 @@ snapshots:
'@adobe/css-tools@4.5.0': {}
'@ark/schema@0.56.6':
dependencies:
'@ark/util': 0.56.6
'@ark/util@0.56.6': {}
'@asamuzakjp/css-color@7.1.3':
dependencies:
'@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
@ -6461,13 +6418,6 @@ snapshots:
is-mergeable-object: 1.1.1
style-dictionary: 5.5.5
'@tokens-studio/tokenscript-interpreter@0.26.0':
dependencies:
arktype: 2.2.7
commander: 14.0.3
readline-sync: 1.4.10
yauzl: 3.4.0
'@tokens-studio/types@0.5.2': {}
'@tybys/wasm-util@0.10.4':
@ -6717,16 +6667,6 @@ snapshots:
aria-query@5.3.2: {}
arkregex@0.0.12:
dependencies:
'@ark/util': 0.56.6
arktype@2.2.7:
dependencies:
'@ark/schema': 0.56.6
'@ark/util': 0.56.6
arkregex: 0.0.12
array-buffer-byte-length@1.0.2:
dependencies:
call-bound: 1.0.4
@ -8555,8 +8495,6 @@ snapshots:
pathval@2.0.1: {}
pend@1.2.0: {}
picocolors@0.2.1: {}
picocolors@1.1.1: {}
@ -8897,8 +8835,6 @@ snapshots:
readdirp@5.1.1: {}
readline-sync@1.4.10: {}
recast@0.23.21:
dependencies:
ast-types: 0.16.3
@ -10018,8 +9954,4 @@ snapshots:
y18n: 5.0.8
yargs-parser: 22.0.0
yauzl@3.4.0:
dependencies:
pend: 1.2.0
yocto-queue@1.2.2: {}

View File

@ -10,7 +10,6 @@
["style-dictionary$default" :as sd]
[app.common.files.tokens :as cfo]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
@ -86,8 +85,7 @@
(let [number? (or (number? value)
(numeric-string? value))
parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))]
(cond
(and parsed-value (not out-of-bounds) number?)
@ -112,8 +110,7 @@
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))]
(if (and parsed-value (not out-of-bounds))
parsed-value
(if out-of-bounds
@ -185,7 +182,7 @@
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [normalized-value (str/lower (str/trim value))
valid? (contains? #{"none" "uppercase" "lowercase" "capitalize"} normalized-value)
valid? (contains? cto/text-case-values normalized-value)
references (seq (cto/find-token-value-references value))]
(cond
valid?

View File

@ -1,166 +1,255 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.tokenscript
"Resolves the workspace tokens with the tokenscript interpreter
(`tokenscript.core`, from penpot/tokenscript-interpreter).
`resolve-tokens` takes the usual map of token name to token and returns
the same map with `:resolved-value` (a value ready for shape attributes:
numbers in pixels, colors as hex strings, composites as maps),
`:resolved-symbol` (the tokenscript value, see
`tokenscript.symbols`) and `:errors` (Penpot error maps, see
`app.main.data.workspace.tokens.errors`) added to each token."
(:require
["@penpot/tokenscript" :refer [BaseSymbolType
ColorSymbol
ListSymbol NumberSymbol
NumberWithUnitSymbol
ProcessorError
processTokens
TokenSymbol
makeConfig]]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.time :as ct]
[app.common.types.token :as cto]
[app.main.data.workspace.tokens.errors :as wte]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[tokenscript.core :as tsc]
[tokenscript.errors :as tse]
[tokenscript.symbols :as sym]))
(l/set-level! :debug)
;; Value conversion -----------------------------------------------------------
;; Config ----------------------------------------------------------------------
(defn- color->hex-string
"Hex string of a color symbol (`#rrggbbaa` when it has alpha)."
[color]
(sym/to-string (tsc/color->hex color)))
(def config (makeConfig))
(defn- symbol->value
"Converts a resolved tokenscript value into the shape used by shape
attributes and the token forms:
;; Class predicates ------------------------------------------------------------
;; Predicates to get information about the tokenscript interpreter symbol type
;; Or to determine the error
- colors: hex strings (alpha as `#rrggbbaa`)
- `rem` values: pixels (16px root font size), `%` values: fractions
- other numbers with unit: the number
- structured tokens (typography, shadows): maps with keyword keys and
vectors
- null: nil"
[v]
(let [entries->map (fn [entries]
(into {} (map (fn [[k v]] [(keyword k) (symbol->value v)])) entries))]
(cond
(nil? v) nil
(sym/null? v) nil
(sym/token? v) (let [content (sym/value v)]
(if (map? content)
(entries->map content)
(mapv symbol->value content)))
(sym/list? v) (mapv symbol->value (sym/value v))
(sym/dictionary? v) (entries->map (sym/value v))
(sym/color? v) (color->hex-string v)
(sym/number-with-unit? v) (let [n (sym/value v)]
(case (:unit v)
"rem" (* n 16)
"%" (/ n 100)
n))
(sym/symbol? v) (sym/value v)
:else v)))
(defn tokenscript-symbol? [v]
(instance? BaseSymbolType v))
;; Value validation -----------------------------------------------------------
;;
;; Tokenscript resolves any expression; these are the Penpot level checks
;; on the resolved value of each token type (ranges, allowed keywords,
;; composite shapes) that decide whether a token can be applied.
(defn structured-token? [v]
(instance? TokenSymbol v))
(defn- words->string
"Values like `700 Italic` resolve to a list of words; font weights are
strings."
[v]
(if (sequential? v) (str/join " " (map str v)) v))
(defn structured-record-token? [^js v]
(and (structured-token? v) (instance? js/Map (.-value v))))
(defn structured-array-token? [^js v]
(and (structured-token? v) (instance? js/Array (.-value v))))
(defn number-with-unit-symbol? [v]
(instance? NumberWithUnitSymbol v))
(defn number-symbol? [v]
(instance? NumberSymbol v))
(defn list-symbol? [v]
(instance? ListSymbol v))
(defn color-symbol? [v]
(instance? ColorSymbol v))
(defn processor-error? [err]
(instance? ProcessorError err))
;; Conversion Tools ------------------------------------------------------------
;; Helpers to convert tokenscript symbols to penpot accepted formats
(defn color-symbol->hex-string [^js v]
(when (color-symbol? v)
(.toString (.to v "hex"))))
(defn color-alpha [^js v]
(if (.isHex v)
1
(or (.getAttribute v "alpha") 1)))
(defn color-symbol->penpot-color [^js v]
{:color (color-symbol->hex-string v)
:opacity (color-alpha v)})
(defn rem-number-with-unit? [v]
(and (number-with-unit-symbol? v)
(= (.-unit v) "rem")))
(defn percent-number-with-unit? [v]
(and (number-with-unit-symbol? v)
(= (.-unit v) "%")))
(defn rem->px [^js v]
(* (.-value v) 16))
(declare tokenscript-symbols->penpot-unit)
(defn font-family-symbols->penpot-unit
"Converts a resolved font-family value into a vector of family names.
Each family name is the string form of its whole entry, which is a list of
word symbols when the family reached tokenscript unquoted."
[^js v]
(when (some? v)
(let [entries (.-value v)]
(if (instance? js/Array entries)
(mapv str entries)
[(str v)]))))
(defn structured-token->penpot-map
"Converts structured token (record or array) to penpot map format.
Structured tokens are non-primitive token types like `typography` or `box-shadow`."
[^js token-symbol]
(if (instance? js/Array (.-value token-symbol))
(mapv tokenscript-symbols->penpot-unit (.-value token-symbol))
(let [entries (es6-iterator-seq (.entries (.-value token-symbol)))]
(into {} (map (fn [[k v]]
(let [k (keyword k)]
;; The font-family member of a composite is a family list
[k (if (= :font-family k)
(font-family-symbols->penpot-unit v)
(tokenscript-symbols->penpot-unit v))])))
entries))))
(defn tokenscript-symbols->penpot-unit [^js v]
(defn- font-family->vector
"Font families are always a flat vector of family names, also when the
value is a single name or references a font family token."
[v]
(cond
(nil? v) nil
(structured-token? v) (structured-token->penpot-map v)
(list-symbol? v) (structured-token->penpot-map v)
(color-symbol? v) (.-value (.to v "hex"))
(rem-number-with-unit? v) (rem->px v)
(percent-number-with-unit? v) (/ (.-value v) 100)
:else (.-value v)))
(string? v) [v]
(sequential? v) (vec (flatten v))
:else v))
(defn resolved-value->penpot-unit
"Converts the resolved value of a token of the given `type`.
Font families need the type: a list of symbols is a list of families at the
top level and the words of a single family name inside an entry."
[type ^js v]
(if (= :font-family type)
(font-family-symbols->penpot-unit v)
(tokenscript-symbols->penpot-unit v)))
(defn- line-height->ratio
"Line heights in pixels are stored relative to the font size. Returns
the ratio or an error when the font size is not available."
[line-height font-size]
(if (and (sym/number-with-unit? line-height) (= "px" (:unit line-height)))
(if (number? font-size)
{:value (/ (sym/value line-height) font-size)}
{:error (wte/error-with-value :error.style-dictionary/composite-line-height-needs-font-size font-size)})
{:value (symbol->value line-height)}))
;; Processors ------------------------------------------------------------------
;; The processor resolves tokens
;; resolved/error tokens get put back into a clojure structure directly during build time
;; For updating tokens we use the `TokenResolver` crud methods from the processing result
;; The `TokenResolver` has detailed information for each tokens dependency graph
(defn- typography->value
"Converts a resolved typography composite, adjusting the fields the
text attributes expect. With `line-height-ratio` a line height in
pixels is converted relative to the font size."
[s line-height-ratio]
(let [value (symbol->value s)]
(if-not (map? value)
{:value value}
(let [value (cond-> value
(contains? value :font-family) (update :font-family font-family->vector)
(contains? value :font-weight) (update :font-weight words->string))
line-height (when (and line-height-ratio (sym/token? s))
(get (sym/value s) "line-height"))]
(if (some? line-height)
(let [{ratio :value error :error} (line-height->ratio line-height (:font-size value))]
(if error
{:error error}
{:value (assoc value :line-height ratio)}))
{:value value})))))
(defn create-token-builder
"Collects resolved tokens during build time into a clojure structure.
Returns Tokenscript Symbols in `:resolved-value` key."
[tokens]
(let [output (volatile! tokens)
(defn- convert-value
"Converts the resolved symbol of a token to the value shape expected by
its type. Returns `{:value v}` or `{:error e}`. Options:
;; When a token is resolved (No parsing / reference errors) we assing `:resolved-value` for the original token
on-resolve
(fn [^js/String token-name ^js/Symbol resolved-symbol]
(vswap! output assoc-in [token-name :resolved-value] resolved-symbol))
:line-height-ratio convert typography line heights in pixels to a
ratio of the font size (default true)"
([type s] (convert-value type s nil))
([type s {:keys [line-height-ratio] :or {line-height-ratio true}}]
(case type
:typography (typography->value s line-height-ratio)
:font-family {:value (font-family->vector (symbol->value s))}
:font-weight {:value (words->string (symbol->value s))}
{:value (symbol->value s)})))
;; When a token contains any errors we assing `:errors` for the original token
on-error
(fn [^js/String token-name ^js/Error _error ^js/String _original-value]
(let [value (get tokens token-name)
default-error [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]]
(vswap! output assoc-in [token-name :errors] default-error)))
(defn- convert-token-value
"`convert-value` for a token: a failing conversion (e.g. a color that
cannot be written as hex) is an error of that token, not of the whole
resolution."
[{:keys [type value]} s]
(try
(convert-value type s)
(catch :default _
{:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)})))
;; Extract the atom value
get-result
(fn [] @output)]
#js {:onResolve on-resolve
:onError on-error
:getResult get-result}))
(defn plugin-value
"Resolved value of a token as exposed by the plugin API: like
`:resolved-value` but with typography line heights left as resolved."
[{:keys [type resolved-symbol]}]
(when (some? resolved-symbol)
(:value (convert-value type resolved-symbol {:line-height-ratio false}))))
(defn- validate-value
"Returns a Penpot error for the resolved `value` of `token`, or nil when
it is acceptable. The interpreter validates the kind of value of each
type; these are the Penpot specific limits on top."
[{:keys [type]} value]
(if (and (number? value) (cto/number-out-of-bounds? value))
(wte/error-with-value :error.token/number-too-large value)
(case type
:text-decoration
(when-not (and (string? value) (cto/valid-text-decoration value))
(wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value))
:font-weight
(when-not (cto/valid-font-weight-variant (str value))
(wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value))
:font-family
(when-not (or (string? value) (and (sequential? value) (every? string? value)))
(wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value))
nil)))
;; Errors ---------------------------------------------------------------------
(defn- missing-reference-of
"Missing token name behind an issue, if the root cause is a missing
token."
[{:keys [code data]}]
(case code
:processor/token-not-found (:token-name data)
:interpreter/unknown-reference (:reference data)
:processor/dependency-error (let [cause (:cause data)]
(when (= :processor/token-not-found (:code cause))
(get-in cause [:data :token-name])))
nil))
(defn- circular?
[{:keys [code data]}]
(or (= :processor/circular-dependency code)
(= :processor/circular-dependency (get-in data [:cause :code]))))
(defn- issue->error
"Maps a tokenscript issue to a Penpot token error. The interpreter only
reports codes and data; the messages are Penpot's. Codes without a
message of their own are an invalid value."
[{:keys [code data] :as issue} value]
(cond
(circular? issue)
(wte/error-with-value :error.token/circular-reference nil)
(missing-reference-of issue)
(wte/error-with-value :error.style-dictionary/missing-reference [(missing-reference-of issue)])
:else
(case code
(:operations/division-by-zero :functions/division-by-zero)
(wte/error-with-value :error.token/division-by-zero nil)
:operations/cannot-mix-units
(wte/error-with-value :error.token/incompatible-units (:units data))
:operations/cannot-compare-units
(wte/error-with-value :error.token/incompatible-units [(:unit-a data) (:unit-b data)])
:interpreter/unknown-function
(wte/error-with-value :error.token/unknown-function (:function-name data))
(:functions/argument-out-of-range :functions/invalid-base)
(wte/error-with-value :error.token/function-argument-out-of-range (:function-name data))
(wte/error-with-value :error.style-dictionary/invalid-token-value value))))
(defn- validation-error
"Maps a failed token type validation of the interpreter (an issue with
an `INVALID_<TYPE>_<REASON>` code) to the Penpot error of the token's
type."
[{:keys [code]} {:keys [type value]}]
(case type
:opacity (wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value)
:stroke-width (wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value)
:text-case (wte/error-with-value :error.style-dictionary/invalid-token-value-text-case value)
:text-decoration (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value)
:font-weight (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)
:font-family (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)
:typography (wte/error-with-value :error.style-dictionary/invalid-token-value-typography value)
:shadow (wte/error-with-value :error.style-dictionary/invalid-token-value-shadow value)
(if (= "INVALID_NUMBER_UNIT" code)
(wte/error-with-value :error.style-dictionary/value-with-units value)
(wte/error-with-value :error.style-dictionary/invalid-token-value value))))
(defn- token-errors
[issues token]
(->> issues
(keep (fn [issue]
(cond
(tse/error-map? issue) (issue->error issue (:value token))
(= :tokenscript/issue (:type issue)) (validation-error issue token))))
(seq)))
;; Resolution -----------------------------------------------------------------
(defn- quote-font-family
"Quotes a font family name so tokenscript reads it as one string instead of
parsing its words (`Red` would become a color, `2P` a number and a word).
Entries with a token reference or already quoted are kept as they are."
"Quotes a font family name so tokenscript reads it as one string instead
of parsing its words (`Red` would become a color, `2P` a number and a
word). Entries with a token reference or already quoted are kept."
[family]
(cond
(or (not (string? family))
@ -183,49 +272,86 @@
(mapv quote-font-family value)
value))
(defn- token-value->tokenscript-value
[type value]
(case type
:font-family (quote-font-families value)
:typography (cond-> value
(map? value) (d/update-when :font-family quote-font-families))
value))
(defn clj->token->tokenscript-token
"Convert penpot token into a format that tokenscript can handle."
(defn- token->input
"Token data for the interpreter. Types are passed by their DTCG names,
which the interpreter's bundled token types validate."
[{:keys [type value]}]
#js {"$type" (name type)
"$value" (clj->js (token-value->tokenscript-value type value))})
(cond-> {:value (case type
:font-family (quote-font-families value)
:typography (cond-> value
(map? value) (d/update-when :font-family quote-font-families))
value)}
(some? type) (assoc :type (get cto/token-type->dtcg-token-type type (name type)))))
(defn clj-tokens->tokenscript-tokens
"Convert penpot map of tokens into tokenscript map structure.
tokenscript accepts a map of [token-name {\"$type\": string, \"$value\": any}]"
(defn- empty-value?
[token]
(nil? (:value token)))
(defn- resolved-token
"Adds the result of the interpreter to `token`: `:resolved-value` and
`:resolved-symbol` for the resolved symbol `s`, or `:errors` for its
`issues`, a failing conversion or a value Penpot doesn't accept."
[token issues s]
(if-let [errors (token-errors issues token)]
(assoc token :errors (vec errors))
(let [{:keys [value error]} (convert-token-value token s)]
(if-let [error (or error (validate-value token value))]
(assoc token :errors [error])
(assoc token
:resolved-value value
:resolved-symbol s)))))
(defn- resolve-tokens*
[tokens]
(let [token-map (js/Map.)]
(doseq [[k token] tokens]
(.set token-map k (clj->token->tokenscript-token token)))
token-map))
(let [tpoint (ct/tpoint-ms)
inputs (into {}
(comp (remove (fn [[_ token]] (empty-value? token)))
(map (fn [[name token]] [name (token->input token)])))
tokens)
result (tsc/build inputs)
resolved (:tokens result)
issues (:issues result)
output (reduce-kv
(fn [acc name token]
(assoc acc name
(if (empty-value? token)
(assoc token :errors [(wte/get-error-code :error.token/empty-input)])
(resolved-token token (get issues name) (get resolved name)))))
{}
tokens)]
(l/dbg :hint "tokenscript/resolve-tokens" :elapsed (tpoint))
output))
(defn process-tokens
"Builds tokens using `tokenscript`."
;; Several components resolve the same token maps on every change (the
;; sidebar, the forms and the propagation each compute the active tokens
;; separately), so the results of the last few maps are kept. Entries are
;; matched by identity first and by equality second: comparing two maps
;; of a few hundred tokens is far cheaper than resolving them again.
(def ^:private cache-size 8)
(defonce ^:private results-cache (volatile! []))
(defn- cached-result
[tokens]
(let [input (clj-tokens->tokenscript-tokens tokens)
result (processTokens input #js {:config config
:builder (create-token-builder tokens)})]
result))
(let [entries @results-cache]
(or (some (fn [[t result]] (when (identical? t tokens) result)) entries)
(some (fn [[t result]] (when (= t tokens) result)) entries))))
(defn update-token
(defn- remember-result
[tokens result]
(vswap! results-cache (fn [entries] (vec (take cache-size (cons [tokens result] entries))))))
(defn resolve-tokens
"Resolves `tokens` (a map of token name to token). Returns the tokens
with `:resolved-value` or `:errors` added."
[tokens]
(or (cached-result tokens)
(let [result (resolve-tokens* tokens)]
(remember-result tokens result)
result)))
(defn resolve-token
"Resolves `token` against `tokens` (the token replaces any token with
the same name). Returns the resolved token."
[tokens token]
(let [result (process-tokens tokens)
resolver (.-resolver result)]
(.updateToken resolver #js {:tokenPath (:name token)
:tokenData (clj->token->tokenscript-token token)})))
;; Main ------------------------------------------------------------------------
(defn resolve-tokens [tokens]
(let [tpoint (ct/tpoint-ms)
result (process-tokens tokens)
elapsed (tpoint)]
(l/dbg :hint "tokenscript/resolve-tokens" :elapsed elapsed)
(.-output result)))
(get (resolve-tokens (assoc tokens (:name token) token)) (:name token)))

View File

@ -735,9 +735,6 @@
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(:name token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/resolved-value->penpot-unit (:type token) resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat

View File

@ -7,9 +7,7 @@
(ns app.main.data.workspace.tokens.color
(:require
[app.common.files.tokens :as cfo]
[app.config :as cf]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]))
[app.main.data.tinycolor :as tinycolor]))
(defn color-bullet-color [token-color-value]
(when-let [tc (tinycolor/valid-color token-color-value)]
@ -19,8 +17,5 @@
(tinycolor/->hex-string tc))))
(defn resolved-token-bullet-color [{:keys [resolved-value] :as token}]
(if (contains? cf/flags :tokenscript)
(when (and resolved-value (ts/color-symbol? resolved-value))
(ts/color-symbol->penpot-color resolved-value))
(when (and resolved-value (cfo/color-token? token))
(color-bullet-color resolved-value))))
(when (and resolved-value (cfo/color-token? token))
(color-bullet-color resolved-value)))

View File

@ -57,6 +57,22 @@
{:error/code :error.token/number-too-large
:error/fn #(str (tr "errors.tokens.number-too-large" %))}
:error.token/division-by-zero
{:error/code :error.token/division-by-zero
:error/fn #(tr "errors.tokens.division-by-zero")}
:error.token/incompatible-units
{:error/code :error.token/incompatible-units
:error/fn #(tr "errors.tokens.incompatible-units" (str/join ", " %))}
:error.token/unknown-function
{:error/code :error.token/unknown-function
:error/fn #(tr "errors.tokens.unknown-function" %)}
:error.token/function-argument-out-of-range
{:error/code :error.token/function-argument-out-of-range
:error/fn #(tr "errors.tokens.function-argument-out-of-range" %)}
;; Surfaced when a token name conflicts with a token-group prefix
;; across the active sets (e.g. one set defines `a`, another set
;; defines `a.b`). DTCG/StyleDictionary cannot represent both as

View File

@ -1,6 +1,11 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.workspace.tokens.format
(:require
[app.main.data.tokenscript :as ts]
[cuerdas.core :as str]))
(def category-dictionary
@ -35,39 +40,10 @@
(str/join "\n")
(str "\n")))
(defn- format-structured-token
"Formats tokenscript Token"
[token-symbol]
(->> (.-value token-symbol)
(.entries)
(es6-iterator-seq)
(format-map-entries)))
(defn format-tokenscript-symbol
[^js tokenscript-symbol]
(cond
(ts/rem-number-with-unit? tokenscript-symbol)
(str (ts/rem->px tokenscript-symbol) "px")
(ts/color-symbol? tokenscript-symbol)
(ts/color-symbol->hex-string tokenscript-symbol)
(ts/structured-record-token? tokenscript-symbol)
(format-structured-token tokenscript-symbol)
(ts/structured-array-token? tokenscript-symbol)
(str/join "\n" (map format-tokenscript-symbol (.-value tokenscript-symbol)))
:else
(.toString tokenscript-symbol)))
(defn format-token-value
"Converts token value of any shape to a string."
[token-value]
(cond
(ts/tokenscript-symbol? token-value)
(format-tokenscript-symbol token-value)
(map? token-value)
(format-map-entries token-value)

View File

@ -6,7 +6,6 @@
(ns app.main.data.workspace.tokens.propagation
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.files.tokens :as cfo]
@ -217,9 +216,7 @@
(when (and tokens-lib tokens-status)
(when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (-> (ts/resolve-tokens tokens-tree)
(d/update-vals #(update % :resolved-value
(partial ts/resolved-value->penpot-unit (:type %))))))
(rx/of (ts/resolve-tokens tokens-tree))
(sd/resolve-tokens tokens-tree))
(rx/mapcat
(fn [sd-tokens]

View File

@ -81,10 +81,7 @@
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)})))))))))

View File

@ -58,10 +58,7 @@
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors

View File

@ -76,10 +76,7 @@
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/resolved-value->penpot-unit (:type token) resolved-value)
resolved-value)]
(let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors

View File

@ -144,16 +144,25 @@
(defn- resolve-value-tokenscript
[tokens prev-token value]
(let [result (ts/update-token tokens (assoc prev-token :value value))
token-result (.-resolved result)]
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
(assoc prev-token
:value value
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name))
{:keys [errors resolved-value]}
;; Remove previous token when renaming a token
(ts/resolve-token (dissoc tokens (:name prev-token)) token)]
(rx/of
(cond
(ts/processor-error? token-result) {:error (wte/error-with-value :error.style-dictionary/missing-reference (some->> (.-dependencyChain token-result)
(seq)
(rest)))}
(instance? js/Error token-result) {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)}
:else {:value token-result}))))
(if (seq errors)
{:error (first errors)}
{:value resolved-value}))))
(defn- resolve-value
[tokens prev-token token-name value]
@ -178,10 +187,7 @@
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors

View File

@ -44,10 +44,7 @@
(sd/resolve-tokens-interactive tokens'))
(rx/mapcat
(fn [resolved-tokens]
(let [resolved-token (cond-> (get resolved-tokens (:name token))
(contains? cf/flags :tokenscript)
(update :resolved-value
(partial ts/resolved-value->penpot-unit (:type token))))]
(let [resolved-token (get resolved-tokens (:name token))]
(cond
(:resolved-value resolved-token)
(rx/of resolved-token)

View File

@ -111,7 +111,7 @@
(defn- generate-tooltip
"Generates a tooltip for a given token"
[is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set is-name-collision errors]
[is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message]
(let [{:keys [name type resolved-value value]} token
resolved-value-theme (:resolved-value theme-token)
resolved-value (or resolved-value-theme resolved-value)
@ -141,8 +141,8 @@
ref-not-in-active-set
(tr "workspace.tokens.ref-not-valid" name)
is-name-collision
(wte/resolve-error-message (first errors))
error-message
error-message
no-valid-value
(tr "workspace.tokens.value-not-valid")
@ -237,9 +237,11 @@
is-viewer? (not can-edit)
tokenscript? (contains? cf/flags :tokenscript)
ref-not-in-active-set
(if (contains? cf/flags :tokenscript)
(seq (:errors resolved-token))
(if tokenscript?
(wte/has-error-code? :error.style-dictionary/missing-reference errors)
(and is-reference?
(not (contains-reference-value? value active-theme-tokens))))
@ -250,6 +252,11 @@
no-valid-value (seq errors)
;; Tokenscript errors carry their own message, as in the edit form.
error-message
(when (and no-valid-value (or tokenscript? name-collision))
(wte/resolve-error-message (first errors)))
errors?
(or ref-not-in-active-set
no-valid-value
@ -297,11 +304,11 @@
on-hover
(mf/use-fn
(mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set name-collision errors)
(mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message)
(fn [_]
(let [theme-token (get active-theme-tokens name)
title (generate-tooltip is-viewer? (first selected-shapes) theme-token token
half-applied? no-valid-value ref-not-in-active-set name-collision errors)]
half-applied? no-valid-value ref-not-in-active-set error-message)]
(reset! tooltip-content* title))))
;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped
@ -346,9 +353,8 @@
[:> icon*
{:icon-id i/broken-link
:class (stl/css :token-pill-icon)
:aria-label (if name-collision
(wte/resolve-error-message (first errors))
(tr "workspace.tokens.missing-reference"))}]
:aria-label (or error-message
(tr "workspace.tokens.missing-reference"))}]
color
[:> swatch* {:background color

View File

@ -232,14 +232,12 @@
k))
(defn- shadow-entry->js
"Converts one resolved shadow entry (a JS Map of field name -> tokenscript
symbol) into a plain JS object using the public member names and the
unit-converted values."
[^js m]
"Converts one resolved shadow entry (a map of field name -> value) into
a plain JS object using the public member names."
[m]
(let [out #js {}]
(.forEach m (fn [sym k]
(obj/set! out (shadow-key->camel k)
(ts/tokenscript-symbols->penpot-unit sym))))
(doseq [[k v] m]
(obj/set! out (shadow-key->camel (name k)) (clj->js v)))
out))
(defn- shadow-resolved-value->js
@ -249,38 +247,29 @@
(when (some? entries)
(into-array (map shadow-entry->js entries))))
(defn- font-families-resolved-value->js
"Converts a resolved fontFamilies value (a tokenscript list symbol) into the
documented `string[]` shape."
[resolved-value]
(some-> (ts/font-family-symbols->penpot-unit resolved-value)
(clj->js)))
(defn- get-resolved-value
[token tokens-tree]
(let [resolved-tokens (ts/resolve-tokens tokens-tree)
resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])]
resolved-token (get resolved-tokens (:name token))
resolved-value (:resolved-value resolved-token)]
(cond
(= :font-family (:type token))
;; A fontFamilies token resolves to a list of families; expose it as the
;; documented `string[]` rather than the raw tokenscript list symbol.
(font-families-resolved-value->js resolved-value)
;; A fontFamilies token resolves to a vector of families; expose it as
;; the documented `string[]`.
(some-> resolved-value clj->js)
(= :typography (:type token))
;; A typography token resolves to a composite; expose it as the documented
;; `TokenTypographyValue[]` rather than the raw tokenscript structure.
(typography-resolved-value->js
(ts/tokenscript-symbols->penpot-unit resolved-value))
;; `TokenTypographyValue[]` with the line height as resolved.
(typography-resolved-value->js (ts/plugin-value resolved-token))
(= :shadow (:type token))
;; A shadow token resolves to a list of composites whose entries the
;; tokenscript unit conversion leaves as raw symbols; expose them as the
;; A shadow token resolves to a list of composites; expose them as the
;; documented `TokenShadowValue[]`.
(shadow-resolved-value->js
(ts/tokenscript-symbols->penpot-unit resolved-value))
(shadow-resolved-value->js resolved-value)
:else
(ts/tokenscript-symbols->penpot-unit resolved-value))))
resolved-value)))
(defn token-proxy? [p]
(obj/type-of? p "TokenProxy"))

View File

@ -110,6 +110,7 @@
[frontend-tests.tokens.style-dictionary-test]
[frontend-tests.tokens.token-errors-test]
[frontend-tests.tokens.token-path-test]
[frontend-tests.tokens.tokenscript-test]
[frontend-tests.tokens.workspace-tokens-remap-test]
[frontend-tests.ui.check-updates-test]
[frontend-tests.ui.color-token-position-test]
@ -269,6 +270,7 @@
'frontend-tests.tokens.style-dictionary-test
'frontend-tests.tokens.token-errors-test
'frontend-tests.tokens.token-path-test
'frontend-tests.tokens.tokenscript-test
'frontend-tests.tokens.logic.tokens-status-test
'frontend-tests.tokens.workspace-tokens-remap-test
'frontend-tests.ui.check-updates-test

View File

@ -0,0 +1,184 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.tokens.tokenscript-test
(:require
[app.common.test-helpers.ids-map :as cthi]
[app.common.types.tokens-lib :as ctob]
[app.main.data.tokenscript :as ts]
[cljs.test :as t :include-macros true]))
(defn- tokens-map
"Tokens map of a library with one set holding `tokens`."
[& tokens]
(let [lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) :name "core")))]
(-> (reduce (fn [lib token] (ctob/add-token lib (cthi/id :core-set) (ctob/make-token token))) lib tokens)
(ctob/get-all-tokens-map))))
(t/deftest resolve-tokens-test
(t/testing "resolves references and converts units to pixels"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "borderRadius.sm" :value "12px" :type :border-radius}
{:name "borderRadius.md-with-dashes" :value "{borderRadius.sm} * 2" :type :border-radius}
{:name "borderRadius.rem" :value "2rem" :type :border-radius}
{:name "opacity.half" :value "50%" :type :opacity}
{:name "borderRadius.large" :value "123456789012345" :type :border-radius}
{:name "borderRadius.largePx" :value "123456789012345px" :type :border-radius}
{:name "borderRadius.largeFn" :value "{borderRadius.sm} * 200000000" :type :border-radius}))]
(t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value])))
(t/is (= 24 (get-in resolved ["borderRadius.md-with-dashes" :resolved-value])))
(t/is (= 32 (get-in resolved ["borderRadius.rem" :resolved-value])))
(t/is (= 0.5 (get-in resolved ["opacity.half" :resolved-value])))
(doseq [name ["borderRadius.large" "borderRadius.largePx" "borderRadius.largeFn"]]
(t/is (nil? (get-in resolved [name :resolved-value])))
(t/is (= :error.token/number-too-large (get-in resolved [name :errors 0 :error/code])))))))
(t/deftest resolve-tokens-colors-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "color.red" :value "#ff0000" :type :color}
{:name "color.alias" :value "{color.red}" :type :color}
{:name "color.alpha" :value "#ff000080" :type :color}
{:name "color.named" :value "red" :type :color}
{:name "color.light" :value "lighten({color.red}, 0.2)" :type :color}))]
(t/is (= "#ff0000" (get-in resolved ["color.alias" :resolved-value])))
(t/is (= "#ff000080" (get-in resolved ["color.alpha" :resolved-value])) "alpha is kept as hex8")
(t/is (= "#FF0000" (get-in resolved ["color.named" :resolved-value])) "css names resolve")
(t/is (string? (get-in resolved ["color.light" :resolved-value])) "color functions resolve to hex")))
(t/deftest resolve-tokens-errors-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "a" :value "{missing}" :type :border-radius}
{:name "b" :value "{a} + 1" :type :border-radius}
{:name "x" :value "{y}" :type :border-radius}
{:name "y" :value "{x}" :type :border-radius}
{:name "z" :value "{x} + 1" :type :border-radius}
{:name "bad" :value "1 ´ 2" :type :border-radius}))]
(t/is (= :error.style-dictionary/missing-reference (get-in resolved ["a" :errors 0 :error/code])))
(t/is (= ["missing"] (get-in resolved ["a" :errors 0 :error/value])))
(t/is (= :error.style-dictionary/missing-reference (get-in resolved ["b" :errors 0 :error/code]))
"dependents report the missing token")
(t/is (= ["missing"] (get-in resolved ["b" :errors 0 :error/value])))
(t/is (= :error.token/circular-reference (get-in resolved ["x" :errors 0 :error/code])))
(t/is (= :error.token/circular-reference (get-in resolved ["y" :errors 0 :error/code])))
(t/is (= :error.token/circular-reference (get-in resolved ["z" :errors 0 :error/code]))
"dependents of a cycle report the cycle")
(t/is (= :error.style-dictionary/invalid-token-value (get-in resolved ["bad" :errors 0 :error/code])))))
(t/deftest resolve-tokens-error-codes-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "zero" :value "1 / 0" :type :number}
{:name "mod-zero" :value "mod(4, 0)" :type :number}
{:name "units" :value "1px * 2em" :type :sizing}
{:name "fn" :value "foo(1)" :type :number}
{:name "ln" :value "ln(0)" :type :number}
{:name "ref" :value "{zero} + 1" :type :number}))
error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))]
(t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "zero")))
(t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "mod-zero")))
(t/is (= {:error/code :error.token/incompatible-units :error/value ["px" "em"]} (error "units")))
(t/is (= {:error/code :error.token/unknown-function :error/value "foo"} (error "fn")))
(t/is (= {:error/code :error.token/function-argument-out-of-range :error/value "ln"} (error "ln")))
(t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref"))
"a token failing through its reference is an invalid value")))
;; A token named "a" and a token named "a.b" can coexist: `{a}` resolves
;; to the token, `{a.b}` to the nested one (#9584).
(t/deftest resolve-tokens-name-collision-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "a" :value "8px" :type :border-radius}
{:name "a.b" :value "12px" :type :border-radius}
{:name "c" :value "{a} + {a.b}" :type :border-radius}))]
(t/is (= 8 (get-in resolved ["a" :resolved-value])))
(t/is (= 12 (get-in resolved ["a.b" :resolved-value])))
(t/is (= 20 (get-in resolved ["c" :resolved-value])))))
(t/deftest resolve-tokens-composites-test
(t/testing "typography and shadow tokens resolve field by field"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "size" :value "16" :type :font-size}
{:name "typo" :type :typography
:value {:font-family ["Roboto" "Arial"]
:font-size "{size} * 2"
:font-weight "700 Italic"
:line-height "48px"}}
{:name "shadow" :type :shadow
:value [{:offset-x "2" :offset-y "{size}" :blur "4" :spread "0"
:color "#000" :inset false}]}))]
(t/is (= {:font-family ["Roboto" "Arial"] :font-size 32 :font-weight "700 Italic" :line-height 1.5}
(get-in resolved ["typo" :resolved-value]))
"line heights in pixels become a ratio of the font size")
(t/is (= [{:offset-x 2 :offset-y 16 :blur 4 :spread 0 :color "#000" :inset false}]
(get-in resolved ["shadow" :resolved-value])))))
(t/testing "font families referencing a font family token are flattened"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "families" :value ["Arial" "sans-serif"] :type :font-family}
{:name "single" :value "Roboto" :type :font-family}
{:name "typo" :value {:font-family ["{families}"]} :type :typography}))]
(t/is (= ["Arial" "sans-serif"] (get-in resolved ["families" :resolved-value])))
(t/is (= ["Roboto"] (get-in resolved ["single" :resolved-value])))
(t/is (= ["Arial" "sans-serif"] (get-in resolved ["typo" :resolved-value :font-family])))))
(t/testing "a typography token with an array value is invalid"
(let [resolved (ts/resolve-tokens (tokens-map {:name "typography.bad" :value ["Roboto"] :type :typography}))]
(t/is (nil? (get-in resolved ["typography.bad" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-typography
(get-in resolved ["typography.bad" :errors 0 :error/code]))))))
(t/deftest resolve-tokens-value-validation-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "opacity.bad" :value "2" :type :opacity}
{:name "stroke.bad" :value "-1" :type :stroke-width}
{:name "case.ok" :value "uppercase" :type :text-case}
{:name "case.bad" :value "wat" :type :text-case}
{:name "weight.ok" :value "bold" :type :font-weight}
{:name "weight.bad" :value "heavyish" :type :font-weight}))]
(t/is (= :error.style-dictionary/invalid-token-value-opacity (get-in resolved ["opacity.bad" :errors 0 :error/code])))
(t/is (= :error.style-dictionary/invalid-token-value-stroke-width (get-in resolved ["stroke.bad" :errors 0 :error/code])))
(t/is (= "uppercase" (get-in resolved ["case.ok" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-text-case (get-in resolved ["case.bad" :errors 0 :error/code])))
(t/is (= "bold" (get-in resolved ["weight.ok" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-font-weight (get-in resolved ["weight.bad" :errors 0 :error/code])))))
;; The interpreter validates the kind of value of each token type: `1foo`
;; and `1 2` are lists, not numbers.
(t/deftest resolve-tokens-type-validation-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "size.glued" :value "1foo" :type :dimensions}
{:name "size.list" :value "1 2" :type :sizing}
{:name "size.ok" :value "1rem" :type :sizing}
{:name "number.glued" :value "1foo" :type :number}
{:name "number.unit" :value "10px" :type :number}
{:name "number.ok" :value "1.5" :type :number}
{:name "opacity.percent" :value "150%" :type :opacity}
{:name "typography.bad" :value {:font-size "1foo" :line-height "1.5"} :type :typography}
{:name "shadow.bad" :value "8" :type :shadow}))
code (fn [name] (get-in resolved [name :errors 0 :error/code]))]
(doseq [name ["size.glued" "size.list" "number.glued"]]
(t/is (nil? (get-in resolved [name :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value (code name))))
(t/is (= "1foo" (get-in resolved ["size.glued" :errors 0 :error/value])))
(t/is (= :error.style-dictionary/value-with-units (code "number.unit")))
(t/is (= :error.style-dictionary/invalid-token-value-opacity (code "opacity.percent")))
(t/is (= :error.style-dictionary/invalid-token-value-typography (code "typography.bad")))
(t/is (= :error.style-dictionary/invalid-token-value-shadow (code "shadow.bad")))
(t/is (= 16 (get-in resolved ["size.ok" :resolved-value])))
(t/is (= 1.5 (get-in resolved ["number.ok" :resolved-value])))))
;; A token with a `nil` value (e.g. a composite typography token saved via
;; the workspace form with no fields filled in) is tagged with an empty
;; input error and does not affect the other tokens.
(t/deftest resolve-tokens-nil-value-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "typography.empty" :value nil :type :typography}
{:name "borderRadius.sm" :value "12px" :type :border-radius}))]
(t/is (nil? (get-in resolved ["typography.empty" :resolved-value])))
(t/is (= :error.token/empty-input (get-in resolved ["typography.empty" :errors 0 :error/code])))
(t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value])))))
(t/deftest resolve-token-test
(let [tokens (tokens-map {:name "a" :value "8px" :type :border-radius})
resolved (ts/resolve-token tokens {:name "b" :value "{a} * 2" :type :border-radius})]
(t/is (= 16 (:resolved-value resolved)))))

View File

@ -1805,6 +1805,10 @@ msgstr "Token has circular reference"
msgid "errors.tokens.composite-line-height-needs-font-size"
msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value."
#: src/app/main/data/workspace/tokens/errors.cljs:62
msgid "errors.tokens.division-by-zero"
msgstr "Invalid token value: division by zero"
#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436
msgid "errors.tokens.empty-field"
msgstr "This field cannot be empty"
@ -1817,10 +1821,18 @@ msgstr "Token value cannot be empty"
msgid "errors.tokens.error-parse"
msgstr "Import Error: Could not parse JSON."
#: src/app/main/data/workspace/tokens/errors.cljs:74
msgid "errors.tokens.function-argument-out-of-range"
msgstr "Invalid token value: the argument of %s() is out of range"
#: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38
msgid "errors.tokens.import-error"
msgstr "Import Error:"
#: src/app/main/data/workspace/tokens/errors.cljs:66
msgid "errors.tokens.incompatible-units"
msgstr "Invalid token value: the units %s cannot be combined"
#: src/app/main/data/workspace/tokens/errors.cljs:54
msgid "errors.tokens.invalid-color"
msgstr "Invalid color value: %s"
@ -1914,6 +1926,10 @@ msgstr "Shadow spread must be greater than or equal to 0."
msgid "errors.tokens.stroke-width-range"
msgstr "Stroke width must be greater than or equal to 0."
#: src/app/main/data/workspace/tokens/errors.cljs:70
msgid "errors.tokens.unknown-function"
msgstr "Invalid token value: unknown function %s"
#: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52
msgid "errors.tokens.value-with-percent"
msgstr "Invalid value: % is not allowed."

View File

@ -1837,6 +1837,10 @@ msgstr ""
"El Line Height depende del Font Size. Añade un Font Size para obtener el "
"valor computado."
#: src/app/main/data/workspace/tokens/errors.cljs:62
msgid "errors.tokens.division-by-zero"
msgstr "Valor de token no válido: división por cero"
#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436
msgid "errors.tokens.empty-field"
msgstr "Este campo no puede estar vacío"
@ -1849,10 +1853,18 @@ msgstr "El valor del token no puede estar vacío"
msgid "errors.tokens.error-parse"
msgstr "Error al importar: No se pudo procesar el JSON."
#: src/app/main/data/workspace/tokens/errors.cljs:74
msgid "errors.tokens.function-argument-out-of-range"
msgstr "Valor de token no válido: el argumento de %s() está fuera de rango"
#: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38
msgid "errors.tokens.import-error"
msgstr "Error al importar:"
#: src/app/main/data/workspace/tokens/errors.cljs:66
msgid "errors.tokens.incompatible-units"
msgstr "Valor de token no válido: las unidades %s no se pueden combinar"
#: src/app/main/data/workspace/tokens/errors.cljs:54
msgid "errors.tokens.invalid-color"
msgstr "Valor de color inválido: %s"
@ -1947,6 +1959,10 @@ msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0."
msgid "errors.tokens.stroke-width-range"
msgstr "Stroke width debe ser mayor o igual a 0."
#: src/app/main/data/workspace/tokens/errors.cljs:70
msgid "errors.tokens.unknown-function"
msgstr "Valor de token no válido: función desconocida %s"
#: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52
msgid "errors.tokens.value-with-percent"
msgstr "Valor no válido: no se permite %."