penpot/frontend/src/app/main/data/tokenscript.cljs
Florian Schrödl 9aa78a536b
✨ 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>
2026-10-22 17:32:52 +02:00

358 lines
14 KiB
Clojure

;; 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
[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]
[tokenscript.core :as tsc]
[tokenscript.errors :as tse]
[tokenscript.symbols :as sym]))
;; Value conversion -----------------------------------------------------------
(defn- color->hex-string
"Hex string of a color symbol (`#rrggbbaa` when it has alpha)."
[color]
(sym/to-string (tsc/color->hex color)))
(defn- symbol->value
"Converts a resolved tokenscript value into the shape used by shape
attributes and the token forms:
- 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)))
;; 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- 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- 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
(string? v) [v]
(sequential? v) (vec (flatten v))
:else 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)}))
(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- convert-value
"Converts the resolved symbol of a token to the value shape expected by
its type. Returns `{:value v}` or `{:error e}`. Options:
: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)})))
(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)})))
(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."
[family]
(cond
(or (not (string? family))
(seq (cto/find-token-value-references family))
(re-matches #"^(['\"]).*\1$" family))
family
(not (str/includes? family "\""))
(str "\"" family "\"")
(not (str/includes? family "'"))
(str "'" family "'")
:else
family))
(defn- quote-font-families
[value]
(if (sequential? value)
(mapv quote-font-family value)
value))
(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]}]
(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- 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 [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))
;; 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 [entries @results-cache]
(or (some (fn [[t result]] (when (identical? t tokens) result)) entries)
(some (fn [[t result]] (when (= t tokens) result)) entries))))
(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]
(get (resolve-tokens (assoc tokens (:name token) token)) (:name token)))