mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
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>
358 lines
14 KiB
Clojure
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)))
|