diff --git a/CHANGES.md b/CHANGES.md index b7ea2e3d6d..f28b54d11b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ ### :sparkles: New features & Enhancements +- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990) - Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202) - Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966) - Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 6a1f2a13dd..8673ef81e3 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -27,6 +27,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.typographies-list :as ctyl] [app.common.types.typography :as ctt] @@ -378,7 +379,7 @@ [:type [:= :set-token]] [:set-id ::sm/uuid] [:token-id ::sm/uuid] - [:attrs [:maybe ctob/schema:token-attrs]]]] + [:attrs [:maybe cto/schema:token-attrs]]]] [:set-token-set [:map {:title "SetTokenSetChange"} diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 6d86bca9ab..67b6217ee6 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -8,8 +8,228 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.i18n :refer [tr]] + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [clojure.set :as set] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [malli.core :as m])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HIGH LEVEL SCHEMAS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Token value + +(defn- token-value-empty-fn + [{:keys [value]}] + (when (or (str/empty? value) + (str/blank? value)) + (tr "workspace.tokens.empty-input"))) + +(def schema:token-value-generic + [::sm/text {:error/fn token-value-empty-fn}]) + +(def schema:token-value-composite-ref + [::sm/text {:error/fn token-value-empty-fn}]) + +(def schema:token-value-font-family + [:vector :string]) + +(def schema:token-value-typography-map + [:map + [:font-family {:optional true} schema:token-value-font-family] + [:font-weight {:optional true} schema:token-value-generic] + [:font-size {:optional true} schema:token-value-generic] + [:line-height {:optional true} schema:token-value-generic] + [:letter-spacing {:optional true} schema:token-value-generic] + [:paragraph-spacing {:optional true} schema:token-value-generic] + [:text-decoration {:optional true} schema:token-value-generic] + [:text-case {:optional true} schema:token-value-generic]]) + +(def schema:token-value-typography + [:or + schema:token-value-typography-map + schema:token-value-composite-ref]) + +(def schema:token-value-shadow-vector + [:vector + [:map + [:offset-x :string] + [:offset-y :string] + [:blur + [:and + :string + [:fn {:error/fn #(tr "workspace.tokens.shadow-token-blur-value-error")} + (fn [blur] + (let [n (d/parse-double blur)] + (or (nil? n) (not (< n 0)))))]]] + [:spread + [:and + :string + [:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")} + (fn [spread] + (let [n (d/parse-double spread)] + (or (nil? n) (not (< n 0)))))]]] + [:color :string] + [:inset {:optional true} :boolean]]]) + +(def schema:token-value-shadow + [:or + schema:token-value-shadow-vector + schema:token-value-composite-ref]) + +(defn make-token-value-schema + [token-type] + [:multi {:dispatch (constantly token-type) + :title "Token Value"} + [:font-family schema:token-value-font-family] + [:typography schema:token-value-typography] + [:shadow schema:token-value-shadow] + [::m/default schema:token-value-generic]]) + +;; Token + +(defn make-token-name-schema + "Dynamically generates a schema to check a token name, adding translated error messages + and two additional validations: + - Min and max length. + - Checks if other token with a path derived from the name already exists at `tokens-tree`. + e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists." + [tokens-tree] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (-> cto/schema:token-name + (sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(and (some? tokens-tree) + (not (ctob/token-name-path-exists? % tokens-tree)))]]) + +(def schema:token-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-schema + [tokens-tree token-type] + [:and + (sm/merge + cto/schema:token-attrs + [:map + [:name (make-token-name-schema tokens-tree)] + [:value (make-token-value-schema token-type)] + [:description {:optional true} schema:token-description]]) + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (not (cto/token-value-self-reference? name value))))]]) + +(defn convert-dtcg-token + "Convert token attributes as they come from a decoded json, with DTCG types, to internal types. + Eg. From this: + + {'name' 'body-text' + 'type' 'typography' + 'value' { + 'fontFamilies' ['Arial' 'Helvetica' 'sans-serif'] + 'fontSize' '16px' + 'fontWeights' 'normal'}} + + to this + {:name 'body-text' + :type :typography + :value { + :font-family ['Arial' 'Helvetica' 'sans-serif'] + :font-size '16px' + :font-weight 'normal'}}" + [token-attrs] + (let [name (get token-attrs "name") + type (get token-attrs "type") + value (get token-attrs "value") + description (get token-attrs "description") + + type (cto/dtcg-token-type->token-type type) + value (case type + :font-family (ctob/convert-dtcg-font-family value) + :typography (ctob/convert-dtcg-typography-composite value) + :shadow (ctob/convert-dtcg-shadow-composite value) + value)] + + (d/without-nils {:name name + :type type + :value value + :description description}))) + +;; Token set + +(defn make-token-set-name-schema + "Generates a dynamic schema to check a token set name: + - Validate name length. + - Checks if other token set with a path derived from the name already exists in the tokens lib." + [tokens-lib set-id] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))} + (fn [name] + (or (nil? tokens-lib) + (let [set (ctob/get-set-by-name tokens-lib name)] + (or (nil? set) (= (ctob/get-id set) set-id)))))]]) + +(def schema:token-set-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-set-schema + [tokens-lib set-id] + (sm/merge + ctob/schema:token-set-attrs + [:map + [:name [:and (make-token-set-name-schema tokens-lib set-id) + [:fn #(ctob/normalized-set-name? %)]]] + [:description {:optional true} schema:token-set-description]])) + +;; Token theme + +(defn make-token-theme-group-schema + "Generates a dynamic schema to check a token theme group: + - Validate group length. + - Checks if other token theme with the same name already exists in the new group in the tokens lib." + [tokens-lib name theme-id] + [:and + [:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))} + (fn [group] + (or (nil? tokens-lib) + (let [theme (ctob/get-theme-by-name tokens-lib group name)] + (or (nil? theme) (= (:id theme) theme-id)))))]]) + +(defn make-token-theme-name-schema + "Generates a dynamic schema to check a token theme name: + - Validate name length. + - Checks if other token theme with the same name already exists in the same group in the tokens lib." + [tokens-lib group theme-id] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))} + (fn [name] + (or (nil? tokens-lib) + (let [theme (ctob/get-theme-by-name tokens-lib group name)] + (or (nil? theme) (= (:id theme) theme-id)))))]]) + +(def schema:token-theme-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-theme-schema + [tokens-lib group name theme-id] + (sm/merge + ctob/schema:token-theme-attrs + [:map + [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here? + [:name (make-token-theme-name-schema tokens-lib group theme-id)] + [:description {:optional true} schema:token-theme-description]])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def parseable-token-value-regexp "Regexp that can be used to parse a number value out of resolved token value. @@ -80,56 +300,6 @@ (defn shapes-applied-all? [ids-by-attributes shape-ids attributes] (every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes)) -(defn token-name->path - "Splits token-name into a path vector split by `.` characters. - - Will concatenate multiple `.` characters into one." - [token-name] - (str/split token-name #"\.+")) - -(defn token-name->path-selector - "Splits token-name into map with `:path` and `:selector` using `token-name->path`. - - `:selector` is the last item of the names path - `:path` is everything leading up the the `:selector`." - [token-name] - (let [path-segments (token-name->path token-name) - last-idx (dec (count path-segments)) - [path [selector]] (split-at last-idx path-segments)] - {:path (seq path) - :selector selector})) - -(defn token-name-path-exists? - "Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists. - - It's not allowed to create a token inside a token. E.g.: - Creating a token with - - {:name \"foo.bar\"} - - in the tokens tree: - - {\"foo\" {:name \"other\"}}" - [token-name token-names-tree] - (let [{:keys [path selector]} (token-name->path-selector token-name) - path-target (reduce - (fn [acc cur] - (let [target (get acc cur)] - (cond - ;; Path segment doesn't exist yet - (nil? target) (reduced false) - ;; A token exists at this path - (:name target) (reduced true) - ;; Continue traversing the true - :else target))) - token-names-tree path)] - (cond - (boolean? path-target) path-target - (get path-target :name) true - :else (-> (get path-target selector) - (seq) - (boolean))))) - (defn color-token? [token] (= (:type token) :color)) diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc new file mode 100644 index 0000000000..bdd80b9741 --- /dev/null +++ b/common/src/app/common/i18n.cljc @@ -0,0 +1,15 @@ +;; 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 INC + +(ns app.common.i18n + "Dummy i18n functions, to be used by code in common that needs translations.") + +(defn tr + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [key & _args] + key) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 845b2cfcad..f350913987 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -58,7 +58,7 @@ (cto/shape-attr->token-attrs attr changed-sub-attr))] (if (some #(contains? tokens %) token-attrs) - (pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs)) + (pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs)) changes))) check-shape diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 6c4ecb6ef1..362d310559 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -11,6 +11,7 @@ #?(:clj [malli.dev.pretty :as mdp]) #?(:clj [malli.dev.virhe :as v]) [app.common.data :as d] + [app.common.json :as json] [app.common.math :as mth] [app.common.pprint :as pp] [app.common.schema.generators :as sg] @@ -92,6 +93,31 @@ [& items] (apply mu/merge (map schema items))) +(defn assoc-key + "Add a key & value to a schema of type [:map]. If the first level node of the schema + is not a map, will do a depth search to find the first map node and add the key there." + ([s k v] + (assoc-key s k {} v)) + ([s k opts v] ;; change order of opts and v to match static schema defintions (e.g. [:something {:optional true} ::sm/integer]) + (let [s (schema s) + v (schema v)] + (if (= (m/type s) :map) + (mu/assoc s k v opts) + (if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))] + (mu/assoc-in s (conj path k) v opts) + s))))) + +(defn dissoc-key + "Remove a key from a schema of type [:map]. If the first level node of the schema + is not a map, will do a depth search to find the first map node and remove the key there." + [s k] + (let [s (schema s)] + (if (= (m/type s) :map) + (mu/dissoc s k) + (if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))] + (mu/update-in s path mu/dissoc k) + s)))) + (defn ref? [s] (m/-ref-schema? s)) @@ -270,6 +296,13 @@ (let [explain (fn [] (me/with-error-messages explain))] ((mdp/prettifier variant message explain default-options))))) +(defn validation-errors + "Checks a value against a schema. If valid, returns nil. If not, returns a list + of english error messages." + [value schema] + (let [explainer (explainer schema)] + (-> value explainer simplify not-empty))) + (defmacro ignoring [expr] (if (:ns &env) @@ -850,6 +883,32 @@ :encode/string str ::oapi/type "boolean"}}) +(defn parse-keyword + [v] + (if (string? v) + (-> v (json/read-kebab-key) (keyword)) + v)) + +(defn format-keyword + [v] + (if (keyword? v) + (-> v (name) (json/write-camel-key)) + v)) + +(register! + {:type ::keyword + :pred keyword? + :type-properties + {:title "keyword" + :description "keyword" + :error/message "expected keyword" + :error/code "errors.invalid-keyword" + :gen/gen sg/keyword + :decode/string parse-keyword + :decode/json parse-keyword + :encode/string format-keyword + ::oapi/type "string"}}) + (register! {:type ::contains-any :min 1 diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 94bf808ae0..5f307ed223 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -9,13 +9,13 @@ [app.common.data :as d] [app.common.schema :as sm] [app.common.schema.generators :as sg] - [clojure.data :as data] + [app.common.time :as ct] [clojure.set :as set] [cuerdas.core :as str] [malli.util :as mu])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; HELPERS +;; GENERAL HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- schema-keys @@ -45,7 +45,7 @@ [token-name token-value] (let [token-references (find-token-value-references token-value) self-reference? (get token-references token-name)] - self-reference?)) + (boolean self-reference?))) (defn references-token? "Recursively check if a value references the token name. Handles strings, maps, and sequences." @@ -59,14 +59,33 @@ (some true? (map #(references-token? % token-name) value)) :else false)) +(defn composite-token-reference? + "Predicate if a composite token is a reference value - a string pointing to another token." + [token-value] + (string? token-value)) + +(defn update-token-value-references + "Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)." + [value old-name new-name] + (cond + (string? value) + (str/replace value + (re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}")) + (str "{" new-name "}")) + (map? value) + (d/update-vals value #(update-token-value-references % old-name new-name)) + (sequential? value) + (mapv #(update-token-value-references % old-name new-name) value) + :else + value)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SCHEMA +;; SCHEMA: Token types ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def token-type->dtcg-token-type {:boolean "boolean" :border-radius "borderRadius" - :shadow "shadow" :color "color" :dimensions "dimension" :font-family "fontFamilies" @@ -77,6 +96,7 @@ :opacity "opacity" :other "other" :rotation "rotation" + :shadow "shadow" :sizing "sizing" :spacing "spacing" :string "string" @@ -94,14 +114,13 @@ "boxShadow" :shadow))) (def composite-token-type->dtcg-token-type - "Custom set of conversion keys for composite typography token with `:line-height` available. - (Penpot doesn't support `:line-height` token)" + "When converting the type of one element inside a composite token, an additional type + :line-height is available, that is not allowed for a standalone token." (assoc token-type->dtcg-token-type :line-height "lineHeights")) (def composite-dtcg-token-type->token-type - "Custom set of conversion keys for composite typography token with `:line-height` available. - (Penpot doesn't support `:line-height` token)" + "Same as above, in the opposite direction." (assoc dtcg-token-type->token-type "lineHeights" :line-height "lineHeight" :line-height)) @@ -109,96 +128,98 @@ (def token-types (into #{} (keys token-type->dtcg-token-type))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SCHEMA: Token +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (def token-name-validation-regex #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") -(def token-name-ref - [:re {:title "TokenNameRef" :gen/gen sg/text} +(def schema:token-name + "A token name can contains letters, numbers, underscores the character $ and dots, but + not start with $ or end with a dot. The $ character does not have any special meaning, + but dots separate token groups (e.g. color.primary.background)." + [:re {:title "TokenName" + :gen/gen sg/text} token-name-validation-regex]) -(def ^:private schema:color - [:map - [:fill {:optional true} token-name-ref] - [:stroke-color {:optional true} token-name-ref]]) +(def schema:token-type + [::sm/one-of {:decode/json (fn [type] + (if (string? type) + (dtcg-token-type->token-type type) + type))} -(def color-keys (schema-keys schema:color)) + token-types]) + +(def schema:token-attrs + [:map {:title "Token"} + [:id ::sm/uuid] + [:name schema:token-name] + [:type schema:token-type] + [:value ::sm/any] + [:description {:optional true} :string] + [:modified-at {:optional true} ::ct/inst]]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SCHEMA: Token application to shape +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; All the following schemas define the `:applied-tokens` attribute of a shape. +;; This attribute is a map -> . +;; Token attributes approximately match shape attributes, but not always. +;; For each schema there is a `*keys` set including all the possible token attributes +;; to which a token of the corresponding type can be applied. +;; Some token types can be applied to some attributes only if the shape has a +;; particular condition (i.e. has a layout itself or is a layout item). (def ^:private schema:border-radius [:map {:title "BorderRadiusTokenAttrs"} - [:r1 {:optional true} token-name-ref] - [:r2 {:optional true} token-name-ref] - [:r3 {:optional true} token-name-ref] - [:r4 {:optional true} token-name-ref]]) + [:r1 {:optional true} schema:token-name] + [:r2 {:optional true} schema:token-name] + [:r3 {:optional true} schema:token-name] + [:r4 {:optional true} schema:token-name]]) (def border-radius-keys (schema-keys schema:border-radius)) -(def ^:private schema:shadow - [:map {:title "ShadowTokenAttrs"} - [:shadow {:optional true} token-name-ref]]) - -(def shadow-keys (schema-keys schema:shadow)) - -(def ^:private schema:stroke-width +(def ^:private schema:color [:map - [:stroke-width {:optional true} token-name-ref]]) + [:fill {:optional true} schema:token-name] + [:stroke-color {:optional true} schema:token-name]]) -(def stroke-width-keys (schema-keys schema:stroke-width)) +(def color-keys (schema-keys schema:color)) (def ^:private schema:sizing-base [:map {:title "SizingBaseTokenAttrs"} - [:width {:optional true} token-name-ref] - [:height {:optional true} token-name-ref]]) + [:width {:optional true} schema:token-name] + [:height {:optional true} schema:token-name]]) (def ^:private schema:sizing-layout-item [:map {:title "SizingLayoutItemTokenAttrs"} - [:layout-item-min-w {:optional true} token-name-ref] - [:layout-item-max-w {:optional true} token-name-ref] - [:layout-item-min-h {:optional true} token-name-ref] - [:layout-item-max-h {:optional true} token-name-ref]]) + [:layout-item-min-w {:optional true} schema:token-name] + [:layout-item-max-w {:optional true} schema:token-name] + [:layout-item-min-h {:optional true} schema:token-name] + [:layout-item-max-h {:optional true} schema:token-name]]) + +(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item)) (def ^:private schema:sizing (-> (reduce mu/union [schema:sizing-base schema:sizing-layout-item]) (mu/update-properties assoc :title "SizingTokenAttrs"))) -(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item)) - (def sizing-keys (schema-keys schema:sizing)) -(def ^:private schema:opacity - [:map {:title "OpacityTokenAttrs"} - [:opacity {:optional true} token-name-ref]]) - -(def opacity-keys (schema-keys schema:opacity)) - (def ^:private schema:spacing-gap [:map {:title "SpacingGapTokenAttrs"} - [:row-gap {:optional true} token-name-ref] - [:column-gap {:optional true} token-name-ref]]) + [:row-gap {:optional true} schema:token-name] + [:column-gap {:optional true} schema:token-name]]) (def ^:private schema:spacing-padding [:map {:title "SpacingPaddingTokenAttrs"} - [:p1 {:optional true} token-name-ref] - [:p2 {:optional true} token-name-ref] - [:p3 {:optional true} token-name-ref] - [:p4 {:optional true} token-name-ref]]) - -(def ^:private schema:spacing-margin - [:map {:title "SpacingMarginTokenAttrs"} - [:m1 {:optional true} token-name-ref] - [:m2 {:optional true} token-name-ref] - [:m3 {:optional true} token-name-ref] - [:m4 {:optional true} token-name-ref]]) - -(def ^:private schema:spacing - (-> (reduce mu/union [schema:spacing-gap - schema:spacing-padding - schema:spacing-margin]) - (mu/update-properties assoc :title "SpacingTokenAttrs"))) - -(def spacing-margin-keys (schema-keys schema:spacing-margin)) - -(def spacing-keys (schema-keys schema:spacing)) + [:p1 {:optional true} schema:token-name] + [:p2 {:optional true} schema:token-name] + [:p3 {:optional true} schema:token-name] + [:p4 {:optional true} schema:token-name]]) (def ^:private schema:spacing-gap-padding (-> (reduce mu/union [schema:spacing-gap @@ -207,6 +228,29 @@ (def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding)) +(def ^:private schema:spacing-margin + [:map {:title "SpacingMarginTokenAttrs"} + [:m1 {:optional true} schema:token-name] + [:m2 {:optional true} schema:token-name] + [:m3 {:optional true} schema:token-name] + [:m4 {:optional true} schema:token-name]]) + +(def spacing-margin-keys (schema-keys schema:spacing-margin)) + +(def ^:private schema:spacing + (-> (reduce mu/union [schema:spacing-gap + schema:spacing-padding + schema:spacing-margin]) + (mu/update-properties assoc :title "SpacingTokenAttrs"))) + +(def spacing-keys (schema-keys schema:spacing)) + +(def ^:private schema:stroke-width + [:map + [:stroke-width {:optional true} schema:token-name]]) + +(def stroke-width-keys (schema-keys schema:stroke-width)) + (def ^:private schema:dimensions (-> (reduce mu/union [schema:sizing schema:spacing @@ -216,91 +260,109 @@ (def dimensions-keys (schema-keys schema:dimensions)) -(def ^:private schema:axis - [:map - [:x {:optional true} token-name-ref] - [:y {:optional true} token-name-ref]]) - -(def axis-keys (schema-keys schema:axis)) - -(def ^:private schema:rotation - [:map {:title "RotationTokenAttrs"} - [:rotation {:optional true} token-name-ref]]) - -(def rotation-keys (schema-keys schema:rotation)) - -(def ^:private schema:font-size - [:map {:title "FontSizeTokenAttrs"} - [:font-size {:optional true} token-name-ref]]) - -(def font-size-keys (schema-keys schema:font-size)) - -(def ^:private schema:letter-spacing - [:map {:title "LetterSpacingTokenAttrs"} - [:letter-spacing {:optional true} token-name-ref]]) - -(def letter-spacing-keys (schema-keys schema:letter-spacing)) - (def ^:private schema:font-family [:map - [:font-family {:optional true} token-name-ref]]) + [:font-family {:optional true} schema:token-name]]) (def font-family-keys (schema-keys schema:font-family)) -(def ^:private schema:text-case - [:map - [:text-case {:optional true} token-name-ref]]) +(def ^:private schema:font-size + [:map {:title "FontSizeTokenAttrs"} + [:font-size {:optional true} schema:token-name]]) -(def text-case-keys (schema-keys schema:text-case)) +(def font-size-keys (schema-keys schema:font-size)) (def ^:private schema:font-weight [:map - [:font-weight {:optional true} token-name-ref]]) + [:font-weight {:optional true} schema:token-name]]) (def font-weight-keys (schema-keys schema:font-weight)) -(def ^:private schema:typography - [:map - [:typography {:optional true} token-name-ref]]) +(def ^:private schema:letter-spacing + [:map {:title "LetterSpacingTokenAttrs"} + [:letter-spacing {:optional true} schema:token-name]]) -(def typography-token-keys (schema-keys schema:typography)) +(def letter-spacing-keys (schema-keys schema:letter-spacing)) -(def ^:private schema:text-decoration - [:map - [:text-decoration {:optional true} token-name-ref]]) +(def ^:private schema:line-height ;; This is not available for standalone tokens, only typography + [:map {:title "LineHeightTokenAttrs"} + [:line-height {:optional true} schema:token-name]]) -(def text-decoration-keys (schema-keys schema:text-decoration)) +(def line-height-keys (schema-keys schema:line-height)) -(def typography-keys (set/union font-size-keys - letter-spacing-keys - font-family-keys - font-weight-keys - text-case-keys - text-decoration-keys - font-weight-keys - typography-token-keys - #{:line-height})) +(def ^:private schema:rotation + [:map {:title "RotationTokenAttrs"} + [:rotation {:optional true} schema:token-name]]) + +(def rotation-keys (schema-keys schema:rotation)) (def ^:private schema:number - (-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]] + (-> (reduce mu/union [schema:line-height schema:rotation]) (mu/update-properties assoc :title "NumberTokenAttrs"))) (def number-keys (schema-keys schema:number)) -(def all-keys (set/union color-keys +(def ^:private schema:opacity + [:map {:title "OpacityTokenAttrs"} + [:opacity {:optional true} schema:token-name]]) + +(def opacity-keys (schema-keys schema:opacity)) + +(def ^:private schema:shadow + [:map {:title "ShadowTokenAttrs"} + [:shadow {:optional true} schema:token-name]]) + +(def shadow-keys (schema-keys schema:shadow)) + +(def ^:private schema:text-case + [:map + [:text-case {:optional true} schema:token-name]]) + +(def text-case-keys (schema-keys schema:text-case)) + +(def ^:private schema:text-decoration + [:map + [:text-decoration {:optional true} schema:token-name]]) + +(def text-decoration-keys (schema-keys schema:text-decoration)) + +(def ^:private schema:typography + [:map + [:typography {:optional true} schema:token-name]]) + +(def typography-token-keys (schema-keys schema:typography)) + +(def typography-keys (set/union font-family-keys + font-size-keys + font-weight-keys + font-weight-keys + letter-spacing-keys + line-height-keys + text-case-keys + text-decoration-keys + typography-token-keys)) + +(def ^:private schema:axis + [:map + [:x {:optional true} schema:token-name] + [:y {:optional true} schema:token-name]]) + +(def axis-keys (schema-keys schema:axis)) + +(def all-keys (set/union axis-keys border-radius-keys - shadow-keys - stroke-width-keys - sizing-keys - opacity-keys - spacing-keys + color-keys dimensions-keys - axis-keys + number-keys + opacity-keys rotation-keys + shadow-keys + sizing-keys + spacing-keys + stroke-width-keys typography-keys - typography-token-keys - number-keys)) + typography-token-keys)) (def ^:private schema:tokens [:map {:title "GenericTokenAttrs"}]) @@ -321,11 +383,28 @@ schema:text-decoration schema:dimensions]) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS for conversion between token attrs and shape attrs +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn token-attr? [attr] (contains? all-keys attr)) +(defn token-attr->shape-attr + "Returns the actual shape attribute affected when a token have been applied + to a given `token-attr`." + [token-attr] + (case token-attr + :fill :fills + :stroke-color :strokes + :stroke-width :strokes + token-attr)) + (defn shape-attr->token-attrs + "Returns the token-attr affected when a given attribute in a shape is changed. + The sub-attr is for attributes that may have multiple values, like strokes + (may be width or color) and layout padding & margin (may have 4 edges)." ([shape-attr] (shape-attr->token-attrs shape-attr nil)) ([shape-attr changed-sub-attr] (cond @@ -367,21 +446,13 @@ (number-keys shape-attr) #{shape-attr} (axis-keys shape-attr) #{shape-attr}))) -(defn token-attr->shape-attr - [token-attr] - (case token-attr - :fill :fills - :stroke-color :strokes - :stroke-width :strokes - token-attr)) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TOKEN SHAPE ATTRIBUTES +;; HELPERS for token attributes by shape type ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def position-attributes #{:x :y}) +(def ^:private position-attributes #{:x :y}) -(def generic-attributes +(def ^:private generic-attributes (set/union color-keys stroke-width-keys rotation-keys @@ -390,20 +461,22 @@ shadow-keys position-attributes)) -(def rect-attributes +(def ^:private rect-attributes (set/union generic-attributes border-radius-keys)) -(def frame-with-layout-attributes +(def ^:private frame-with-layout-attributes (set/union rect-attributes spacing-gap-padding-keys)) -(def text-attributes +(def ^:private text-attributes (set/union generic-attributes typography-keys number-keys)) (defn shape-type->attributes + "Returns what token attributes may be applied to a shape depending on its type + and if it is a frame with a layout." [type is-layout] (case type :bool generic-attributes @@ -419,12 +492,14 @@ nil)) (defn appliable-attrs-for-shape - "Returns intersection of shape `attributes` for `shape-type`." + "Returns which ones of the given `attributes` can be applied to a shape + of type `shape-type` and `is-layout`." [attributes shape-type is-layout] (set/intersection attributes (shape-type->attributes shape-type is-layout))) (defn any-appliable-attr-for-shape? - "Checks if `token-type` supports given shape `attributes`." + "Returns if any of the given `attributes` can be applied to a shape + of type `shape-type` and `is-layout`." [attributes token-type is-layout] (d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout))) @@ -435,42 +510,6 @@ typography-keys #{:fill})) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TOKENS IN SHAPES -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- toggle-or-apply-token - "Remove any shape attributes from token if they exists. - Othewise apply token attributes." - [shape token] - (let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)] - (merge {} shape-leftover token-leftover))) - -(defn- token-from-attributes [token attributes] - (->> (map (fn [attr] [attr (:name token)]) attributes) - (into {}))) - -(defn- apply-token-to-attributes [{:keys [shape token attributes]}] - (let [token (token-from-attributes token attributes)] - (toggle-or-apply-token shape token))) - -(defn apply-token-to-shape - [{:keys [shape token attributes] :as _props}] - (let [applied-tokens (apply-token-to-attributes {:shape shape - :token token - :attributes attributes})] - (update shape :applied-tokens #(merge % applied-tokens)))) - -(defn unapply-token-id [shape attributes] - (update shape :applied-tokens d/without-keys attributes)) - -(defn unapply-layout-item-tokens - "Unapplies all layout item related tokens from shape." - [shape] - (let [layout-item-attrs (set/union sizing-layout-item-keys - spacing-margin-keys)] - (unapply-token-id shape layout-item-attrs))) - (def tokens-by-input "A map from input name to applicable token for that input." {:width #{:sizing :dimensions} @@ -500,7 +539,33 @@ :stroke-color #{:color}}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TYPOGRAPHY +;; HELPERS for tokens application +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- generate-attr-map [token attributes] + (->> (map (fn [attr] [attr (:name token)]) attributes) + (into {}))) + +(defn apply-token-to-shape + "Applies the token to the given attributes in the shape." + [{:keys [shape token attributes] :as _props}] + (let [map-to-apply (generate-attr-map token attributes)] + (update shape :applied-tokens #(merge % map-to-apply)))) + +(defn unapply-tokens-from-shape + "Removes any token applied to the given attributes in the shape." + [shape attributes] + (update shape :applied-tokens d/without-keys attributes)) + +(defn unapply-layout-item-tokens + "Unapplies all layout item related tokens from shape." + [shape] + (let [layout-item-attrs (set/union sizing-layout-item-keys + spacing-margin-keys)] + (unapply-tokens-from-shape shape layout-item-attrs))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS for typography tokens ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn split-font-family @@ -563,32 +628,3 @@ (when (font-weight-values weight) (cond-> {:weight weight} italic? (assoc :style "italic"))))) - -(defn typography-composite-token-reference? - "Predicate if a typography composite token is a reference value - a string pointing to another reference token." - [token-value] - (string? token-value)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SHADOW -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn shadow-composite-token-reference? - "Predicate if a shadow composite token is a reference value - a string pointing to another reference token." - [token-value] - (string? token-value)) - -(defn update-token-value-references - "Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)." - [value old-name new-name] - (cond - (string? value) - (str/replace value - (re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}")) - (str "{" new-name "}")) - (map? value) - (d/update-vals value #(update-token-value-references % old-name new-name)) - (sequential? value) - (mapv #(update-token-value-references % old-name new-name) value) - :else - value)) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 5e8e18e14a..573dac181d 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -114,25 +114,19 @@ [o] (instance? Token o)) -(def schema:token-attrs - [:map {:title "Token"} - [:id ::sm/uuid] - [:name cto/token-name-ref] - [:type [::sm/one-of cto/token-types]] - [:value ::sm/any] - [:description {:optional true} :string] - [:modified-at {:optional true} ::ct/inst]]) - (declare make-token) (def schema:token - [:and {:gen/gen (->> (sg/generator schema:token-attrs) + [:and {:gen/gen (->> (sg/generator cto/schema:token-attrs) (sg/fmap #(make-token %)))} - (sm/required-keys schema:token-attrs) + (sm/required-keys cto/schema:token-attrs) [:fn token?]]) (def ^:private check-token-attrs - (sm/check-fn schema:token-attrs :hint "expected valid params for token")) + (sm/check-fn cto/schema:token-attrs :hint "expected valid params for token")) + +(def decode-token-attrs + (sm/lazy-decoder cto/schema:token-attrs sm/json-transformer)) (def check-token (sm/check-fn schema:token :hint "expected valid token")) @@ -317,10 +311,18 @@ [o] (instance? TokenSetLegacy o)) +(declare make-token-set) +(declare normalized-set-name?) + +(def schema:token-set-name + [:and + :string + [:fn #(normalized-set-name? %)]]) ;; The #() is necessary because the function is only declared, not defined + (def schema:token-set-attrs [:map {:title "TokenSet"} [:id ::sm/uuid] - [:name :string] + [:name schema:token-set-name] [:description {:optional true} :string] [:modified-at {:optional true} ::ct/inst] [:tokens {:optional true @@ -342,8 +344,6 @@ :string schema:token] [:fn d/ordered-map?]]]]) -(declare make-token-set) - (def schema:token-set [:schema {:gen/gen (->> (sg/generator schema:token-set-attrs) (sg/fmap #(make-token-set %)))} @@ -404,12 +404,25 @@ (split-set-name name)) (cpn/join-path :separator set-separator :with-spaces? false)))) +(defn normalized-set-name? + "Check if a set name is normalized (no extra spaces)." + [name] + (= name (normalize-set-name name))) + (defn replace-last-path-name "Replaces the last element in a `path` vector with `name`." [path name] (-> (into [] (drop-last path)) (conj name))) +(defn make-child-name + "Generate the name of a set child of `parent-set` adding the name `name`." + [parent-set name] + (if-let [parent-path (get-set-path parent-set)] + (->> (concat parent-path (split-set-name name)) + (join-set-path)) + (normalize-set-name name))) + ;; The following functions will be removed after refactoring the internal structure of TokensLib, ;; since we'll no longer need group prefixes to differentiate between sets and set-groups. @@ -1370,10 +1383,13 @@ Will return a value that matches this schema: (def ^:private check-tokens-lib-map (sm/check-fn schema:tokens-lib-map :hint "invalid tokens-lib internal data structure")) +(defn tokens-lib? + [o] + (instance? TokensLib o)) + (defn valid-tokens-lib? [o] - (and (instance? TokensLib o) - (valid? o))) + (and (tokens-lib? o) (valid? o))) (defn- ensure-hidden-theme "A helper that is responsible to ensure that the hidden theme always @@ -1435,6 +1451,50 @@ Will return a value that matches this schema: (rename copy-name) (reid (uuid/next)))))) +(defn- token-name->path-selector + "Splits token-name into map with `:path` and `:selector` using `token-name->path`. + + `:selector` is the last item of the names path + `:path` is everything leading up the the `:selector`." + [token-name] + (let [path-segments (get-token-path {:name token-name}) + last-idx (dec (count path-segments)) + [path [selector]] (split-at last-idx path-segments)] + {:path (seq path) + :selector selector})) + +(defn token-name-path-exists? + "Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists. + + It's not allowed to create a token inside a token. E.g.: + Creating a token with + + {:name \"foo.bar\"} + + in the tokens tree: + + {\"foo\" {:name \"other\"}}" + [token-name tokens-tree] + (let [{:keys [path selector]} (token-name->path-selector token-name) + path-target (reduce + (fn [acc cur] + (let [target (get acc cur)] + (cond + ;; Path segment doesn't exist yet + (nil? target) (reduced false) + ;; A token exists at this path + (:name target) (reduced true) + ;; Continue traversing the true + :else target))) + tokens-tree + path)] + (cond + (boolean? path-target) path-target + (get path-target :name) true + :else (-> (get path-target selector) + (seq) + (boolean))))) + ;; === Import / Export from JSON format ;; Supported formats: diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index a16b625c88..cf8b04f6c4 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -6,34 +6,34 @@ (ns common-tests.files.tokens-test (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [clojure.test :as t])) (t/deftest test-parse-token-value (t/testing "parses double from a token value" - (t/is (= {:value 100.1 :unit nil} (cft/parse-token-value "100.1"))) - (t/is (= {:value -9.0 :unit nil} (cft/parse-token-value "-9")))) + (t/is (= {:value 100.1 :unit nil} (cfo/parse-token-value "100.1"))) + (t/is (= {:value -9.0 :unit nil} (cfo/parse-token-value "-9")))) (t/testing "trims white-space" - (t/is (= {:value -1.3 :unit nil} (cft/parse-token-value " -1.3 ")))) + (t/is (= {:value -1.3 :unit nil} (cfo/parse-token-value " -1.3 ")))) (t/testing "parses unit: px" - (t/is (= {:value 70.3 :unit "px"} (cft/parse-token-value " 70.3px ")))) + (t/is (= {:value 70.3 :unit "px"} (cfo/parse-token-value " 70.3px ")))) (t/testing "parses unit: %" - (t/is (= {:value -10.0 :unit "%"} (cft/parse-token-value "-10%")))) + (t/is (= {:value -10.0 :unit "%"} (cfo/parse-token-value "-10%")))) (t/testing "parses unit: px") (t/testing "returns nil for any invalid characters" - (t/is (nil? (cft/parse-token-value " -1.3a ")))) + (t/is (nil? (cfo/parse-token-value " -1.3a ")))) (t/testing "doesnt accept invalid double" - (t/is (nil? (cft/parse-token-value ".3"))))) + (t/is (nil? (cfo/parse-token-value ".3"))))) (t/deftest token-applied-test (t/testing "matches passed token with `:token-attributes`" - (t/is (true? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (true? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "doesn't match empty token" - (t/is (nil? (cft/token-applied? {} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (nil? (cfo/token-applied? {} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "does't match passed token `:id`" - (t/is (nil? (cft/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (nil? (cfo/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "doesn't match passed `:token-attributes`" - (t/is (nil? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y}))))) + (t/is (nil? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y}))))) (t/deftest shapes-ids-by-applied-attributes (t/testing "Returns set of matched attributes that fit the applied token" @@ -54,7 +54,7 @@ shape-applied-x-y shape-applied-all shape-applied-none] - expected (cft/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)] + expected (cfo/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)] (t/is (= (:x expected) (shape-ids shape-applied-x shape-applied-x-y shape-applied-all))) @@ -62,34 +62,21 @@ shape-applied-x-y shape-applied-all))) (t/is (= (:z expected) (shape-ids shape-applied-all))) - (t/is (true? (cft/shapes-applied-all? expected (shape-ids shape-applied-all) attributes))) - (t/is (false? (cft/shapes-applied-all? expected (apply shape-ids shapes) attributes))) + (t/is (true? (cfo/shapes-applied-all? expected (shape-ids shape-applied-all) attributes))) + (t/is (false? (cfo/shapes-applied-all? expected (apply shape-ids shapes) attributes))) (shape-ids shape-applied-x shape-applied-x-y shape-applied-all)))) (t/deftest tokens-applied-test (t/testing "is true when single shape matches the token and attributes" - (t/is (true? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} + (t/is (true? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} {:applied-tokens {:x "b"}}] #{:x})))) (t/testing "is false when no shape matches the token or attributes" - (t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}} + (t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}} {:applied-tokens {:x "b"}}] #{:x}))) - (t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} + (t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} {:applied-tokens {:x "a"}}] #{:y}))))) - -(t/deftest name->path-test - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo.bar.baz"))) - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz"))) - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz....")))) - -(t/deftest token-name-path-exists?-test - (t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}}))) - (t/is (false? (cft/token-name-path-exists? "other" {"border-radius" {:name "sm"}}))) - (t/is (false? (cft/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}})))) diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index ff1f54d3d7..eb031e4868 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -255,28 +255,28 @@ (cls/generate-update-shapes [(:id frame1)] (fn [shape] (-> shape - (cto/unapply-token-id [:r1 :r2 :r3 :r4]) - (cto/unapply-token-id [:rotation]) - (cto/unapply-token-id [:opacity]) - (cto/unapply-token-id [:stroke-width]) - (cto/unapply-token-id [:stroke-color]) - (cto/unapply-token-id [:fill]) - (cto/unapply-token-id [:width :height]))) + (cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4]) + (cto/unapply-tokens-from-shape [:rotation]) + (cto/unapply-tokens-from-shape [:opacity]) + (cto/unapply-tokens-from-shape [:stroke-width]) + (cto/unapply-tokens-from-shape [:stroke-color]) + (cto/unapply-tokens-from-shape [:fill]) + (cto/unapply-tokens-from-shape [:width :height]))) (:objects page) {}) (cls/generate-update-shapes [(:id text1)] (fn [shape] (-> shape - (cto/unapply-token-id [:font-size]) - (cto/unapply-token-id [:letter-spacing]) - (cto/unapply-token-id [:font-family]))) + (cto/unapply-tokens-from-shape [:font-size]) + (cto/unapply-tokens-from-shape [:letter-spacing]) + (cto/unapply-tokens-from-shape [:font-family]))) (:objects page) {}) (cls/generate-update-shapes [(:id circle1)] (fn [shape] (-> shape - (cto/unapply-token-id [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w]) - (cto/unapply-token-id [:m1 :m2 :m3 :m4]))) + (cto/unapply-tokens-from-shape [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w]) + (cto/unapply-tokens-from-shape [:m1 :m2 :m3 :m4]))) (:objects page) {})) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index 694f4f51fb..fb93bd2541 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -8,20 +8,19 @@ (:require [app.common.schema :as sm] [app.common.types.token :as cto] - [app.common.uuid :as uuid] [clojure.test :as t])) (t/deftest test-valid-token-name-schema ;; Allow regular namespace token names - (t/is (true? (sm/validate cto/token-name-ref "Foo"))) - (t/is (true? (sm/validate cto/token-name-ref "foo"))) - (t/is (true? (sm/validate cto/token-name-ref "FOO"))) - (t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz"))) + (t/is (true? (sm/validate cto/schema:token-name "Foo"))) + (t/is (true? (sm/validate cto/schema:token-name "foo"))) + (t/is (true? (sm/validate cto/schema:token-name "FOO"))) + (t/is (true? (sm/validate cto/schema:token-name "Foo.Bar.Baz"))) ;; Disallow trailing tokens - (t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz...."))) + (t/is (false? (sm/validate cto/schema:token-name "Foo.Bar.Baz...."))) ;; Disallow multiple separator dots - (t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz"))) + (t/is (false? (sm/validate cto/schema:token-name "Foo..Bar.Baz"))) ;; Disallow any special characters - (t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar"))) - (t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar"))) - (t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar")))) + (t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar"))) + (t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar"))) + (t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar")))) diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 30bd570860..150ffcfb08 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -678,35 +678,35 @@ (t/deftest list-active-themes-tokens-bug-taiga-10617 (let [tokens-lib (-> (ctob/make-tokens-lib) - (ctob/add-set (ctob/make-token-set :name "Mode / Dark" + (ctob/add-set (ctob/make-token-set :name "Mode/Dark" :tokens {"red" (ctob/make-token :name "red" :type :color :value "#700000")})) - (ctob/add-set (ctob/make-token-set :name "Mode / Light" + (ctob/add-set (ctob/make-token-set :name "Mode/Light" :tokens {"red" (ctob/make-token :name "red" :type :color :value "#ff0000")})) - (ctob/add-set (ctob/make-token-set :name "Device / Desktop" + (ctob/add-set (ctob/make-token-set :name "Device/Desktop" :tokens {"border1" (ctob/make-token :name "border1" :type :border-radius :value 30)})) - (ctob/add-set (ctob/make-token-set :name "Device / Mobile" + (ctob/add-set (ctob/make-token-set :name "Device/Mobile" :tokens {"border1" (ctob/make-token :name "border1" :type :border-radius :value 50)})) (ctob/add-theme (ctob/make-token-theme :group "App" :name "Mobile" - :sets #{"Mode / Dark" "Device / Mobile"})) + :sets #{"Mode/Dark" "Device/Mobile"})) (ctob/add-theme (ctob/make-token-theme :group "App" :name "Web" - :sets #{"Mode / Dark" "Mode / Light" "Device / Desktop"})) + :sets #{"Mode/Dark" "Mode/Light" "Device/Desktop"})) (ctob/add-theme (ctob/make-token-theme :group "Brand" :name "Brand A" - :sets #{"Mode / Dark" "Mode / Light" "Device / Desktop" "Device / Mobile"})) + :sets #{"Mode/Dark" "Mode/Light" "Device/Desktop" "Device/Mobile"})) (ctob/add-theme (ctob/make-token-theme :group "Brand" :name "Brand B" :sets #{})) @@ -2013,3 +2013,11 @@ (t/is (some? imported-ref)) (t/is (= (:type original-ref) (:type imported-ref))) (t/is (= (:value imported-ref) (:value original-ref)))))))) + +(t/deftest token-name-path-exists?-test + (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}}))) + (t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}}))) + (t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}})))) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 42db0235b8..474fa93552 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -8,7 +8,7 @@ (:require ["@tokens-studio/sd-transforms" :as sd-transforms] ["style-dictionary$default" :as sd] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] @@ -85,7 +85,7 @@ [value] (let [number? (or (number? value) (numeric-string? value)) - parsed-value (cft/parse-token-value 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))] @@ -111,7 +111,7 @@ "Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`. If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] - (let [parsed-value (cft/parse-token-value 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))] (if (and parsed-value (not out-of-bounds)) @@ -129,7 +129,7 @@ If the `value` is parseable but is out of range returns a map with `warnings`." [value] (let [missing-references? (seq (cto/find-token-value-references value)) - parsed-value (cft/parse-token-value value) + parsed-value (cfo/parse-token-value value) out-of-scope (not (<= 0 (:value parsed-value) 1)) references (seq (cto/find-token-value-references value))] (cond (and parsed-value (not out-of-scope)) @@ -153,7 +153,7 @@ If the `value` is parseable but is out of range returns a map with `warnings`." [value] (let [missing-references? (seq (cto/find-token-value-references value)) - parsed-value (cft/parse-token-value value) + parsed-value (cfo/parse-token-value value) out-of-scope (< (:value parsed-value) 0)] (cond (and parsed-value (not out-of-scope)) @@ -251,7 +251,7 @@ :font-size-value font-size-value})] (or error (try - (when-let [{:keys [unit value]} (cft/parse-token-value line-height-value)] + (when-let [{:keys [unit value]} (cfo/parse-token-value line-height-value)] (case unit "%" (/ value 100) "px" (/ value font-size-value) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 31ff1747d8..f89a31091d 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -7,7 +7,7 @@ (ns app.main.data.workspace.tokens.application (:require [app.common.data :as d] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.types.component :as ctk] [app.common.types.shape.layout :as ctsl] [app.common.types.shape.radius :as ctsr] @@ -648,11 +648,11 @@ shape-ids (d/nilv (keys shapes) []) any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value]) + resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) resolved-value (if (contains? cf/flags :tokenscript) (ts/tokenscript-symbols->penpot-unit resolved-value) resolved-value) - tokenized-attributes (cft/attributes-map attributes token) + tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] (rx/concat (rx/of @@ -711,7 +711,7 @@ ptk/WatchEvent (watch [_ _ _] (rx/of - (let [remove-token #(when % (cft/remove-attributes-for-token attributes token-name %))] + (let [remove-token #(when % (cfo/remove-attributes-for-token attributes token-name %))] (dwsh/update-shapes shape-ids (fn [shape] @@ -740,7 +740,7 @@ (get token-properties (:type token)) unapply-tokens? - (cft/shapes-token-applied? token shapes (or attrs all-attributes attributes)) + (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) shape-ids (map :id shapes)] diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index 8e83666d80..809d7340d3 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -6,7 +6,7 @@ (ns app.main.data.workspace.tokens.color (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.config :as cf] [app.main.data.tinycolor :as tinycolor] [app.main.data.tokenscript :as ts])) @@ -22,5 +22,5 @@ (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 (cft/color-token? token)) + (when (and resolved-value (cfo/color-token? token)) (color-bullet-color resolved-value)))) diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 0a21d54464..4daccd05b8 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -195,27 +195,30 @@ (defn create-token-set [token-set] + (assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema? (ptk/reify ::create-token-set - ptk/UpdateEvent - (update [_ state] - ;; Clear possible local state - (update state :workspace-tokens dissoc :token-set-new-path)) - ptk/WatchEvent (watch [it state _] - (let [data (dsh/lookup-file-data state) - tokens-lib (get data :tokens-lib) - token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))] - (if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set))) - (rx/of (ntf/show {:content (tr "errors.token-set-already-exists") - :type :toast - :level :error - :timeout 9000})) - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/set-token-set (ctob/get-id token-set) token-set))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) + (let [data (dsh/lookup-file-data state) + changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/set-token-set (ctob/get-id token-set) token-set))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes)))))) + +(defn rename-token-set + [token-set new-name] + (assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema after renaming? + (assert (string? new-name) "a new name is required") ;; TODO should assert normalized-set-name? + (ptk/reify ::update-token-set + ptk/WatchEvent + (watch [it state _] + (let [data (dsh/lookup-file-data state) + changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/rename-token-set (ctob/get-id token-set) new-name))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes)))))) (defn rename-token-set-group [set-group-path set-group-fname] @@ -227,26 +230,6 @@ (rx/of (dch/commit-changes changes)))))) -(defn update-token-set - [token-set name] - (ptk/reify ::update-token-set - ptk/WatchEvent - (watch [it state _] - (let [data (dsh/lookup-file-data state) - name (ctob/normalize-set-name name (ctob/get-name token-set)) - tokens-lib (get data :tokens-lib)] - - (if (ctob/get-set-by-name tokens-lib name) - (rx/of (ntf/show {:content (tr "errors.token-set-already-exists") - :type :toast - :level :error - :timeout 9000})) - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/rename-token-set (ctob/get-id token-set) name))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) - (defn duplicate-token-set [id] (ptk/reify ::duplicate-token-set @@ -522,7 +505,7 @@ (ctob/get-id token-set) token-id)] (let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set))) - unames (map :name tokens) + unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib suffix (tr "workspace.tokens.duplicate-suffix") copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix) new-token (-> token diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 0fe34d1f25..7f1244dcad 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -49,7 +49,6 @@ (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] - (let [form (mf/use-ctx context) disabled? (or (and (some? form) (or (not (:valid @form)) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index d4470f63f0..bcb44b83c5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -9,7 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.types.shape.layout :as ctsl] [app.common.types.token :as ctt] [app.main.data.modal :as modal] @@ -47,9 +47,9 @@ ;; Actions --------------------------------------------------------------------- (defn attribute-actions [token selected-shapes attributes] - (let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes) + (let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes) shape-ids (into #{} (map :id selected-shapes))] - {:all-selected? (cft/shapes-applied-all? ids-by-attributes shape-ids attributes) + {:all-selected? (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes) :shape-ids shape-ids :selected-pred #(seq (% ids-by-attributes))})) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs index ee1de768ec..30e19452b3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs @@ -6,48 +6,25 @@ (ns app.main.ui.workspace.tokens.management.forms.color (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.schema :as sm] - [app.common.types.token :as cto] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] - [app.util.i18n :refer [tr]] - [cuerdas.core :as str] [rumext.v2 :as mf])) -(defn- token-value-error-fn - [{:keys [value]}] - (when (or (str/empty? value) - (str/blank? value)) - (tr "workspace.tokens.empty-input"))) - -(defn- make-schema - [tokens-tree _] - (sm/schema - [:and - [:map - [:name - [:and - [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) - [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] - - [:value [::sm/text {:error/fn token-value-error-fn}]] - - [:color-result {:optional true} ::sm/any] - - [:description {:optional true} - [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] - - [:fn {:error/field :value - :error/fn #(tr "workspace.tokens.self-reference")} - (fn [{:keys [name value]}] - (when (and name value) - (nil? (cto/token-value-self-reference? name value))))]])) - (mf/defc form* - [props] - (let [props (mf/spread-props props {:make-schema make-schema + [{:keys [token token-type] :as props}] + (let [initial + (mf/with-memo [token] + {:type token-type + :name (:name token "") + :value (:value token "") + :description (:description token "") + :color-result ""}) + + props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type) + (sm/dissoc-key :id) + (sm/assoc-key :color-result :string)) + :initial initial :input-component token.controls/color-input*})] [:> generic/form* props])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 85fd13a828..b87bb0d675 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -156,7 +156,6 @@ color-resolved (get-in @form [:data :color-result] "") - valid-color (or (tinycolor/valid-color value) (tinycolor/valid-color color-resolved)) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs index 729d35e764..b7bba0ea45 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs @@ -6,6 +6,8 @@ (ns app.main.ui.workspace.tokens.management.forms.font-family (:require + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.common.types.token :as cto] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] @@ -35,6 +37,11 @@ {:type token-type})) props (mf/spread-props props {:token token :token-type token-type + :make-schema #(-> (cfo/make-token-schema %1 token-type) + (sm/dissoc-key :id) + ;; The value as edited in the form is a simple stirng. + ;; It's converted to vector in the validator. + (sm/assoc-key :value cfo/schema:token-value-generic)) :validator validate-font-family-token :input-component token.controls/fonts-combobox*})] [:> generic/form* props])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index 70797979c6..b0aef352ae 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.workspace.tokens.management.forms.form-container (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.types.tokens-lib :as ctob] [app.main.refs :as refs] [app.main.ui.workspace.tokens.management.forms.color :as color] @@ -28,7 +27,7 @@ token-path (mf/with-memo [token] - (cft/token-name->path (:name token))) + (ctob/get-token-path token)) tokens-tree-in-selected-set (mf/with-memo [token-path tokens-in-selected-set] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index 001547ff83..b0f36ec06c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -8,9 +8,8 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.schema :as sm] - [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.main.constants :refer [max-input-length]] [app.main.data.helpers :as dh] @@ -36,13 +35,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(defn- token-value-error-fn - [{:keys [value]}] - (when (or (str/empty? value) - (str/blank? value)) - (tr "workspace.tokens.empty-input"))) - - (defn get-value-for-validator [active-tab value value-subfield form-type] @@ -59,29 +51,6 @@ value)) -(defn- default-make-schema - [tokens-tree _] - (sm/schema - [:and - [:map - [:name - [:and - [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) - [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] - - [:value [::sm/text {:error/fn token-value-error-fn}]] - - [:description {:optional true} - [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] - - [:fn {:error/field :value - :error/fn #(tr "workspace.tokens.self-reference")} - (fn [{:keys [name value]}] - (when (and name value) - (nil? (cto/token-value-self-reference? name value))))]])) - (mf/defc form* [{:keys [token validator @@ -97,11 +66,12 @@ value-subfield input-value-placeholder] :as props}] - (let [make-schema (or make-schema default-make-schema) + (let [make-schema (or make-schema #(-> (cfo/make-token-schema % token-type) + (sm/dissoc-key :id))) input-component (or input-component token.controls/input*) validate-token (or validator default-validate-token) - active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite)) + active-tab* (mf/use-state #(if (cfo/is-reference? token) :reference :composite)) active-tab (deref active-tab*) token @@ -132,9 +102,10 @@ (make-schema tokens-tree-in-selected-set active-tab)) initial - (mf/with-memo [token] + (mf/with-memo [token initial] (or initial - {:name (:name token "") + {:type token-type + :name (:name token "") :value (:value token "") :description (:description token "")})) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 995d61fec7..99579abab1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -8,9 +8,9 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] @@ -51,7 +51,7 @@ ;; Entering form without a value - show no error just resolve nil (nil? token-value) (rx/of nil) ;; Validate refrence string - (cto/shadow-composite-token-reference? token-value) (default-validate-token params) + (cto/composite-token-reference? token-value) (default-validate-token params) ;; Validate composite token :else (let [params (-> params @@ -253,6 +253,7 @@ [:> reference-form* {:token token :tokens tokens}])])) +;; TODO: use cfo/make-schema:token-value and extend it with shadow and reference fields (defn- make-schema [tokens-tree active-tab] (sm/schema @@ -262,10 +263,10 @@ [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [:map diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 2800578287..26a50a2f50 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -8,9 +8,9 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] [app.main.ui.ds.foundations.assets.icon :as i] @@ -48,7 +48,7 @@ ;; Entering form without a value - show no error just resolve nil (nil? token-value) (rx/of nil) ;; Validate refrence string - (cto/typography-composite-token-reference? token-value) (default-validate-token props) + (cto/composite-token-reference? token-value) (default-validate-token props) ;; Validate composite token :else (-> props @@ -208,6 +208,7 @@ ;; SCHEMA +;; TODO: use cfo/make-schema:token-value and extend it with typography and reference fields (defn- make-schema [tokens-tree active-tab] (sm/schema @@ -217,10 +218,10 @@ [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [:map diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index f17156dd45..ab04acbd64 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -1,13 +1,11 @@ (ns app.main.ui.workspace.tokens.management.forms.validators (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.main.data.style-dictionary :as sd] [app.main.data.workspace.tokens.errors :as wte] - [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str])) @@ -29,7 +27,8 @@ ;; When creating a new token we dont have a name yet or invalid name, ;; but we still want to resolve the value to show in the form. ;; So we use a temporary token name that hopefully doesn't clash with any of the users token names - (not (sm/valid? cto/token-name-ref (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) + (not (sm/valid? cto/schema:token-name (:name token))) + (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) tokens' (cond-> tokens ;; Remove previous token when renaming a token (not= (:name token) (:name prev-token)) @@ -89,23 +88,3 @@ [token-name token-vals] (when (some #(cto/token-value-self-reference? token-name %) token-vals) (wte/get-error-code :error.token/direct-self-reference))) - - - -;; This is used in plugins - -(defn- make-token-name-schema - "Generate a dynamic schema validation to check if a token path derived - from the name already exists at `tokens-tree`." - [tokens-tree] - [:and - [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) - [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]) - -(defn validate-token-name - [tokens-tree name] - (let [schema (make-token-name-schema tokens-tree) - explainer (sm/explainer schema)] - (-> name explainer sm/simplify not-empty))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index a6f14b4320..94ac737ed8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -10,7 +10,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.path-names :as cpn] [app.common.types.token :as ctt] [app.config :as cf] @@ -157,9 +157,9 @@ (defn- applied-all-attributes? [token selected-shapes attributes] - (let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes) + (let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes) shape-ids (into #{} xf:map-id selected-shapes)] - (cft/shapes-applied-all? ids-by-attributes shape-ids attributes))) + (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes))) (defn attributes-match-selection? [selected-shapes attrs & {:keys [selected-inside-layout?]}] @@ -181,7 +181,7 @@ resolved-token (get active-theme-tokens (:name token)) has-selected? (pos? (count selected-shapes)) - is-reference? (cft/is-reference? token) + is-reference? (cfo/is-reference? token) contains-path? (str/includes? name ".") attributes (as-> (get dwta/token-properties type) $ @@ -194,7 +194,7 @@ applied? (if has-selected? - (cft/shapes-token-applied? token selected-shapes attributes) + (cfo/shapes-token-applied? token selected-shapes attributes) false) half-applied? @@ -224,7 +224,7 @@ no-valid-value) color - (when (cft/color-token? token) + (when (cfo/color-token? token) (or (dwtc/resolved-token-bullet-color resolved-token) (dwtc/resolved-token-bullet-color token))) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs index e16fad82b6..fd35710a74 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs @@ -63,7 +63,8 @@ (st/emit! (dwtl/start-token-set-edition id)))))] [:> controlled-sets-list* - {:token-sets token-sets + {:tokens-lib tokens-lib + :token-sets token-sets :is-token-set-active token-set-active? :is-token-set-group-active token-set-group-active? @@ -80,6 +81,6 @@ :on-toggle-token-set on-toggle-token-set-click :on-toggle-token-set-group on-toggle-token-set-group-click - :on-update-token-set sets-helpers/on-update-token-set + :on-update-token-set (partial sets-helpers/on-update-token-set tokens-lib) :on-update-token-set-group sets-helpers/on-update-token-set-group :on-create-token-set sets-helpers/on-create-token-set}])) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs index ab6331bd47..e7b9bf98c6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs @@ -1,9 +1,13 @@ (ns app.main.ui.workspace.tokens.sets.helpers (:require + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] + [app.util.i18n :refer [tr]] [potok.v2.core :as ptk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -11,9 +15,18 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn on-update-token-set - [token-set name] - (st/emit! (dwtl/clear-token-set-edition) - (dwtl/update-token-set token-set name))) + [tokens-lib token-set name] + (let [name (ctob/normalize-set-name name (ctob/get-name token-set)) + errors (sm/validation-errors name (cfo/make-token-set-name-schema + tokens-lib + (ctob/get-id token-set)))] + (st/emit! (dwtl/clear-token-set-edition)) + (if (empty? errors) + (st/emit! (dwtl/rename-token-set token-set name)) + (st/emit! (ntf/show {:content (tr "errors.token-set-already-exists") + :type :toast + :level :error + :timeout 9000}))))) (defn on-update-token-set-group [path name] @@ -21,15 +34,15 @@ (dwtl/rename-token-set-group path name))) (defn on-create-token-set - [parent-set name] - (let [;; FIXME: this code should be reusable under helper under - ;; common types namespace - name - (if-let [parent-path (ctob/get-set-path parent-set)] - (->> (concat parent-path (ctob/split-set-name name)) - (ctob/join-set-path)) - (ctob/normalize-set-name name)) - token-set (ctob/make-token-set :name name)] - + [tokens-lib parent-set name] + (let [name (ctob/make-child-name parent-set name) + errors (sm/validation-errors name (cfo/make-token-set-name-schema tokens-lib nil))] (st/emit! (ptk/data-event ::ev/event {::ev/name "create-token-set" :name name}) - (dwtl/create-token-set token-set)))) + (dwtl/clear-token-set-creation)) + (if (empty? errors) + (let [token-set (ctob/make-token-set :name name)] + (st/emit! (dwtl/create-token-set token-set))) + (st/emit! (ntf/show {:content (tr "errors.token-set-already-exists") + :type :toast + :level :error + :timeout 9000}))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs index 89d5fa4895..ad510bc7d6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs @@ -321,6 +321,7 @@ on-select on-toggle-set on-toggle-set-group + tokens-lib token-sets new-path edition-id]}] @@ -408,7 +409,7 @@ :on-drop on-drop :on-reset-edition on-reset-edition - :on-edit-submit sets-helpers/on-create-token-set}] + :on-edit-submit (partial sets-helpers/on-create-token-set tokens-lib)}] :else [:> sets-tree-set* @@ -434,7 +435,8 @@ :on-edit-submit on-edit-submit-set}]))))) (mf/defc controlled-sets-list* - [{:keys [token-sets + [{:keys [tokens-lib + token-sets selected on-update-token-set on-update-token-set-group @@ -486,6 +488,7 @@ {:is-draggable draggable? :new-path new-path :edition-id edition-id + :tokens-lib tokens-lib :token-sets token-sets :selected selected :on-select on-select diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index ba4eab678b..597d4a681e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -7,8 +7,11 @@ (ns app.main.ui.workspace.tokens.themes.create-modal (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] [app.common.logic.tokens :as clt] + [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] [app.main.constants :refer [max-input-length]] [app.main.data.event :as ev] @@ -30,32 +33,9 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as k] [cuerdas.core :as str] - [malli.core :as m] - [malli.error :as me] [potok.v2.core :as ptk] [rumext.v2 :as mf])) -;; Schemas --------------------------------------------------------------------- - -(defn- theme-name-schema - "Generate a dynamic schema validation to check if a theme path derived from the name already exists at `tokens-tree`." - [{:keys [group theme-id tokens-lib]}] - (m/-simple-schema - {:type :token/name-exists - :pred (fn [name] - (if tokens-lib - (let [theme (ctob/get-theme-by-name tokens-lib group name)] - (or (nil? theme) - (= (ctob/get-id theme) theme-id))) - true)) ;; if still no library exists, cannot be duplicate - :type-properties {:error/fn #(tr "workspace.tokens.theme-name-already-exists")}})) - -(defn validate-theme-name - [tokens-lib group theme-id name] - (let [schema (theme-name-schema {:tokens-lib tokens-lib :theme-id theme-id :group group}) - validation (m/explain schema (str/trim name))] - (me/humanize validation))) - ;; Form Component -------------------------------------------------------------- (mf/defc empty-themes @@ -181,26 +161,43 @@ theme-groups) current-group* (mf/use-state (:group theme)) current-group (deref current-group*) + current-name* (mf/use-state (:name theme)) + current-name (deref current-name*) + group-errors* (mf/use-state nil) + group-errors (deref group-errors*) name-errors* (mf/use-state nil) name-errors (deref name-errors*) on-update-group (mf/use-fn - (mf/deps on-change-field) + (mf/deps on-change-field tokens-lib current-name) (fn [value] - (reset! current-group* value) - (on-change-field :group value))) + (let [errors (sm/validation-errors value (cfo/make-token-theme-group-schema + tokens-lib + current-name + (ctob/get-id theme)))] + (reset! group-errors* errors) + (if (empty? errors) + (do + (reset! current-group* value) + (on-change-field :group value)) + (on-change-field :group ""))))) on-update-name (mf/use-fn (mf/deps on-change-field tokens-lib current-group) (fn [event] (let [value (-> event dom/get-target dom/get-value) - errors (validate-theme-name tokens-lib current-group (ctob/get-id theme) value)] + errors (sm/validation-errors value (cfo/make-token-theme-name-schema + tokens-lib + current-group + (ctob/get-id theme)))] (reset! name-errors* errors) (mf/set-ref-val! theme-name-ref value) (if (empty? errors) - (on-change-field :name value) + (do + (reset! current-name* value) + (on-change-field :name value)) (on-change-field :name "")))))] [:div {:class (stl/css :edit-theme-inputs-wrapper)} @@ -210,6 +207,7 @@ :placeholder (tr "workspace.tokens.label.group-placeholder") :default-selected (:group theme) :options (clj->js options) + :has-error (d/not-empty? group-errors) :on-change on-update-group}]] [:div {:class (stl/css :group-input-wrapper)} @@ -262,6 +260,7 @@ (mf/defc edit-create-theme* [{:keys [change-view theme on-save is-editing has-prev-view]}] (let [ordered-token-sets (mf/deref refs/workspace-ordered-token-sets) + tokens-lib (mf/deref refs/tokens-lib) token-sets (mf/deref refs/workspace-token-sets-tree) current-theme* (mf/use-state theme) @@ -363,7 +362,8 @@ [:div {:class (stl/css :sets-list-wrapper)} [:> wts/controlled-sets-list* - {:token-sets token-sets + {:tokens-lib tokens-lib + :token-sets token-sets :is-token-set-active token-set-active? :is-token-set-group-active token-set-group-active? :on-select on-click-token-set diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index da85575fb3..6328a5bafd 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -7,33 +7,41 @@ (ns app.plugins.tokens (:require [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] + [app.main.data.style-dictionary :as sd] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] - [app.main.ui.workspace.tokens.management.forms.validators :as form-validator] - [app.main.ui.workspace.tokens.themes.create-modal :as theme-form] + [app.plugins.shape :as shape] [app.plugins.utils :as u] [app.util.object :as obj] + [beicon.v2.core :as rx] [clojure.datafy :refer [datafy]])) +;; === Token + (defn- apply-token-to-shapes [file-id set-id id shape-ids attrs] - (let [token (u/locate-token file-id set-id id) - kw-attrs (into #{} (map keyword attrs))] - (if (some #(not (cto/token-attr? %)) kw-attrs) + (let [token (u/locate-token file-id set-id id)] + (if (some #(not (cto/token-attr? %)) attrs) (u/display-not-valid :applyToSelected attrs) (st/emit! (dwta/toggle-token {:token token - :attrs kw-attrs + :attrs attrs :shape-ids shape-ids :expand-with-children false}))))) +(defn token-proxy? [p] + (obj/type-of? p "TokenProxy")) + (defn token-proxy [plugin-id file-id set-id id] - (obj/reify {:name "TokenSetProxy"} + (obj/reify {:name "TokenProxy" + :wrap u/wrap-errors} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$set-id {:enumerable false :get (constantly set-id)} @@ -48,18 +56,12 @@ (fn [_] (let [token (u/locate-token file-id set-id id)] (ctob/get-name token))) + :schema (cfo/make-token-name-schema + (some-> (u/locate-tokens-lib file-id) + (ctob/get-tokens set-id))) :set (fn [_ value] - (let [tokens-lib (u/locate-tokens-lib file-id) - errors (form-validator/validate-token-name - (ctob/get-tokens tokens-lib set-id) - value)] - (cond - (some? errors) - (u/display-not-valid :name (first errors)) - - :else - (st/emit! (dwtl/update-token set-id id {:name value})))))} + (st/emit! (dwtl/update-token set-id id {:name value})))} :type {:this true @@ -73,17 +75,31 @@ :get (fn [_] (let [token (u/locate-token file-id set-id id)] - (:value token)))} + (:value token))) + :schema (let [token (u/locate-token file-id set-id id)] + (cfo/make-token-value-schema (:type token))) + :set + (fn [_ value] + (st/emit! (dwtl/update-token set-id id {:value value})))} :description {:this true :get (fn [_] (let [token (u/locate-token file-id set-id id)] - (ctob/get-description token)))} + (ctob/get-description token))) + :schema cfo/schema:token-description + :set + (fn [_ value] + (st/emit! (dwtl/update-token set-id id {:description value})))} :duplicate (fn [] + ;; TODO: + ;; - add function duplicate-token in tokens-lib, that allows to specify the new id + ;; - use this function in dwtl/duplicate-token + ;; - return the new token proxy using the locally forced id + ;; - do the same with sets and themes (let [token (u/locate-token file-id set-id id) token' (ctob/make-token (-> (datafy token) (dissoc :id @@ -96,17 +112,27 @@ (st/emit! (dwtl/delete-token set-id id))) :applyToShapes - (fn [shapes attrs] - (apply-token-to-shapes file-id set-id id (map :id shapes) attrs)) + {:schema [:tuple + [:vector [:fn shape/shape-proxy?]] + [:maybe [:set ::sm/keyword]]] + :fn (fn [shapes attrs] + (apply-token-to-shapes file-id set-id id (map :id shapes) attrs))} :applyToSelected - (fn [attrs] - (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes file-id set-id id selected attrs))))) + {:schema [:tuple [:maybe [:set ::sm/keyword]]] + :fn (fn [attrs] + (let [selected (get-in @st/state [:workspace-local :selected])] + (apply-token-to-shapes file-id set-id id selected attrs)))})) + +;; === Token Set + +(defn token-set-proxy? [p] + (obj/type-of? p "TokenSetProxy")) (defn token-set-proxy [plugin-id file-id id] - (obj/reify {:name "TokenSetProxy"} + (obj/reify {:name "TokenSetProxy" + :wrap u/wrap-errors} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -120,15 +146,13 @@ (fn [_] (let [set (u/locate-token-set file-id id)] (ctob/get-name set))) + :schema (cfo/make-token-set-name-schema + (u/locate-tokens-lib file-id) + id) :set - (fn [_ value] + (fn [_ name] (let [set (u/locate-token-set file-id id)] - (cond - (not (string? value)) - (u/display-not-valid :name value) - - :else - (st/emit! (dwtl/update-token-set set value)))))} + (st/emit! (dwtl/rename-token-set set name))))} :active {:this true @@ -138,6 +162,7 @@ (let [tokens-lib (u/locate-tokens-lib file-id) set (u/locate-token-set file-id id)] (ctob/token-set-active? tokens-lib (ctob/get-name set)))) + :schema ::sm/boolean :set (fn [_ value] (let [set (u/locate-token-set file-id id)] @@ -153,8 +178,7 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib)] + (let [tokens-lib (u/locate-tokens-lib file-id)] (->> (ctob/get-tokens tokens-lib id) (vals) (map #(token-proxy plugin-id file-id id (:id %))) @@ -165,8 +189,7 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) + (let [tokens-lib (u/locate-tokens-lib file-id) tokens (ctob/get-tokens tokens-lib id)] (->> tokens (vals) @@ -181,55 +204,56 @@ (apply array))))} :getTokenById - (fn [token-id] - (cond - (not (string? token-id)) - (u/display-not-valid :getTokenById token-id) - - :else - (let [token-id (uuid/parse token-id) - token (u/locate-token file-id id token-id)] - (when (some? token) - (token-proxy plugin-id file-id id token-id))))) + {:schema [:tuple ::sm/uuid] + :fn (fn [token-id] + (let [token (u/locate-token file-id id token-id)] + (when (some? token) + (token-proxy plugin-id file-id id token-id))))} :addToken - (fn [type-str name value] - (let [type (cto/dtcg-token-type->token-type type-str) - value (case type - :font-family (ctob/convert-dtcg-font-family (js->clj value)) - :typography (ctob/convert-dtcg-typography-composite (js->clj value)) - :shadow (ctob/convert-dtcg-shadow-composite (js->clj value)) - (js->clj value))] - (cond - (nil? type) - (u/display-not-valid :addTokenType type-str) - - (not (string? name)) - (u/display-not-valid :addTokenName name) - - :else - (let [token (ctob/make-token {:type type - :name name - :value value})] - (st/emit! (dwtl/create-token id token)) - (token-proxy plugin-id file-id (:id set) (:id token)))))) + {:schema (fn [args] + [:tuple (-> (cfo/make-token-schema + (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + :decode/options {:key-fn identity} + :fn (fn [attrs] + (let [tokens-lib (u/locate-tokens-lib file-id) + tokens-tree (ctob/get-tokens-in-active-sets tokens-lib) + token (ctob/make-token attrs)] + (->> (assoc tokens-tree (:name token) token) + (sd/resolve-tokens-interactive) + (rx/subs! + (fn [resolved-tokens] + (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))] + (if resolved-value + (st/emit! (dwtl/create-token id token)) + (u/display-not-valid :addToken (str errors))))))) + ;; TODO: as the addToken function is synchronous, we must return the newly created + ;; token even if the validator will throw it away if the resolution fails. + ;; This will be solved with the TokenScript resolver, that is syncronous. + (token-proxy plugin-id file-id id (:id token))))} :duplicate (fn [] - (let [set (u/locate-token-set file-id id) - set' (ctob/make-token-set (-> (datafy set) - (dissoc :id - :modified-at)))] - (st/emit! (dwtl/create-token-set set')) - (token-set-proxy plugin-id file-id (:id set')))) + (st/emit! (dwtl/duplicate-token-set id))) :remove (fn [] (st/emit! (dwtl/delete-token-set id))))) +(defn token-theme-proxy? [p] + (obj/type-of? p "TokenThemeProxy")) + (defn token-theme-proxy [plugin-id file-id id] - (obj/reify {:name "TokenThemeProxy"} + (obj/reify {:name "TokenThemeProxy" + :wrap u/wrap-errors} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -250,15 +274,15 @@ (fn [_] (let [theme (u/locate-token-theme file-id id)] (:group theme))) + :schema (let [theme (u/locate-token-theme file-id id)] + (cfo/make-token-theme-group-schema + (u/locate-tokens-lib file-id) + (:name theme) + (:id theme))) :set - (fn [_ value] + (fn [_ group] (let [theme (u/locate-token-theme file-id id)] - (cond - (not (string? value)) - (u/display-not-valid :group value) - - :else - (st/emit! (dwtl/update-token-theme id (assoc theme :group value))))))} + (st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))} :name {:this true @@ -266,20 +290,16 @@ (fn [_] (let [theme (u/locate-token-theme file-id id)] (:name theme))) + :schema (let [theme (u/locate-token-theme file-id id)] + (cfo/make-token-theme-name-schema + (u/locate-tokens-lib file-id) + (:id theme) + (:group theme))) :set - (fn [_ value] - (let [theme (u/locate-token-theme file-id id) - errors (theme-form/validate-theme-name - (u/locate-tokens-lib file-id) - (:group theme) - id - value)] - (cond - (some? errors) - (u/display-not-valid :name (first errors)) - - :else - (st/emit! (dwtl/update-token-theme id (assoc theme :name value))))))} + (fn [_ name] + (let [theme (u/locate-token-theme file-id id)] + (when name + (st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))} :active {:this true @@ -288,6 +308,7 @@ (fn [_] (let [tokens-lib (u/locate-tokens-lib file-id)] (ctob/theme-active? tokens-lib id))) + :schema ::sm/boolean :set (fn [_ value] (st/emit! (dwtl/set-token-theme-active id value)))} @@ -300,14 +321,16 @@ {:this true :get (fn [_])} :addSet - (fn [tokenSet] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get tokenSet :name)))))) + {:schema [:tuple [:fn token-set-proxy?]] + :fn (fn [token-set] + (let [theme (u/locate-token-theme file-id id)] + (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))} :removeSet - (fn [tokenSet] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get tokenSet :name)))))) + {:schema [:tuple [:fn token-set-proxy?]] + :fn (fn [token-set] + (let [theme (u/locate-token-theme file-id id)] + (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))} :duplicate (fn [] @@ -324,7 +347,8 @@ (defn tokens-catalog [plugin-id file-id] - (obj/reify {:name "TokensCatalog"} + (obj/reify {:name "TokensCatalog" + :wrap u/wrap-errors} :$plugin {:enumerable false :get (constantly plugin-id)} :$id {:enumerable false :get (constantly file-id)} @@ -333,10 +357,10 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) - themes (->> (ctob/get-themes tokens-lib) - (remove #(= (:id %) uuid/zero)))] + (let [tokens-lib (u/locate-tokens-lib file-id) + themes (when tokens-lib + (->> (ctob/get-themes tokens-lib) + (remove #(= (:id %) uuid/zero))))] (apply array (map #(token-theme-proxy plugin-id file-id (ctob/get-id %)) themes))))} :sets @@ -344,58 +368,47 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) - sets (ctob/get-sets tokens-lib)] + (let [tokens-lib (u/locate-tokens-lib file-id) + sets (when tokens-lib + (ctob/get-sets tokens-lib))] (apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))} :addTheme - (fn [group name] - (cond - (not (string? group)) - (u/display-not-valid :addThemeGroup group) - - (not (string? name)) - (u/display-not-valid :addThemeName name) - - :else - (let [theme (ctob/make-token-theme {:group group - :name name})] - (st/emit! (dwtl/create-token-theme theme)) - (token-theme-proxy plugin-id file-id (:id theme))))) + {:schema (fn [attrs] + [:tuple (-> (sm/schema (cfo/make-token-theme-schema + (u/locate-tokens-lib file-id) + (or (obj/get attrs "group") "") + (or (obj/get attrs "name") "") + nil)) + (sm/dissoc-key :id))]) ;; We don't allow plugins to set the id + :fn (fn [attrs] + (let [theme (ctob/make-token-theme attrs)] + (st/emit! (dwtl/create-token-theme theme)) + (token-theme-proxy plugin-id file-id (:id theme))))} :addSet - (fn [name] - (cond - (not (string? name)) - (u/display-not-valid :addSetName name) + {:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema + (u/locate-tokens-lib file-id) + nil)) + (sm/dissoc-key :id))] ;; We don't allow plugins to set the id - :else - (let [set (ctob/make-token-set {:name name})] - (st/emit! (dwtl/create-token-set set)) - (token-set-proxy plugin-id file-id (:id set))))) + :fn (fn [attrs] + (let [attrs (update attrs :name ctob/normalize-set-name) + set (ctob/make-token-set attrs)] + (st/emit! (dwtl/create-token-set set)) + (token-set-proxy plugin-id file-id (ctob/get-id set))))} :getThemeById - (fn [theme-id] - (cond - (not (string? theme-id)) - (u/display-not-valid :getThemeById theme-id) - - :else - (let [theme-id (uuid/parse theme-id) - theme (u/locate-token-theme file-id theme-id)] - (when (some? theme) - (token-theme-proxy plugin-id file-id theme-id))))) + {:schema [:tuple ::sm/uuid] + :fn (fn [theme-id] + (let [theme (u/locate-token-theme file-id theme-id)] + (when (some? theme) + (token-theme-proxy plugin-id file-id theme-id))))} :getSetById - (fn [set-id] - (cond - (not (string? set-id)) - (u/display-not-valid :getSetById set-id) - - :else - (let [set-id (uuid/parse set-id) - set (u/locate-token-set file-id set-id)] - (when (some? set) - (token-set-proxy plugin-id file-id set-id))))))) + {:schema [:tuple ::sm/uuid] + :fn (fn [set-id] + (let [set (u/locate-token-set file-id set-id)] + (when (some? set) + (token-set-proxy plugin-id file-id set-id))))})) diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 907a8dd352..8df47b2995 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,6 +9,8 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.json :as json] + [app.common.schema :as sm] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] @@ -61,17 +63,20 @@ (defn locate-token-theme [file-id id] (let [tokens-lib (locate-tokens-lib file-id)] - (ctob/get-theme tokens-lib id))) + (when (some? tokens-lib) + (ctob/get-theme tokens-lib id)))) (defn locate-token-set [file-id set-id] (let [tokens-lib (locate-tokens-lib file-id)] - (ctob/get-set tokens-lib set-id))) + (when (some? tokens-lib) + (ctob/get-set tokens-lib set-id)))) (defn locate-token [file-id set-id token-id] (let [tokens-lib (locate-tokens-lib file-id)] - (ctob/get-token tokens-lib set-id token-id))) + (when (some? tokens-lib) + (ctob/get-token tokens-lib set-id token-id)))) (defn locate-presence [session-id] @@ -218,7 +223,8 @@ (defn display-not-valid [code value] - (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) + (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)) + nil) (defn reject-not-valid [reject code value] @@ -226,7 +232,35 @@ (.error js/console msg) (reject msg))) +(defn coerce + "Decodes a javascript object into clj and check against schema. If schema validation fails, + displays a not-valid message with the code and hint provided and returns nil." + [attrs schema code hint] + (let [decoder (sm/decoder schema sm/json-transformer) + explainer (sm/explainer schema) + attrs (-> attrs json/->clj decoder)] + (if-let [explain (explainer attrs)] + (display-not-valid code (str hint " " (sm/humanize-explain explain))) + attrs))) + (defn mixed-value [values] (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) + +(defn wrap-errors + "Function wrapper to be used in plugin proxies methods to handle errors. + When an exception is thrown, a readable error message is output to the console + and the exception is captured." + [f] + (fn [] + (let [args (js-arguments)] + (try + (.apply f nil args) + (catch :default cause + (display-not-valid (ex-message cause) (obj/stringify args)) + (if-let [explain (-> cause ex-data ::sm/explain)] + (println (sm/humanize-explain explain)) + (js/console.log (ex-data cause))) + (js/console.log (.-stack cause)) + nil))))) \ No newline at end of file diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 9eab6a6018..b59f695868 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -8,6 +8,7 @@ "A i18n foundation." (:require [app.common.data :as d] + [app.common.i18n] [app.common.logging :as log] [app.common.time :as ct] [app.config :as cf] @@ -210,3 +211,7 @@ (fn [_ _ pv cv] (when (not= pv cv) (ct/set-default-locale cv)))) + +;; We set the real translation function in the common i18n namespace, +;; so that when common code calls (tr ...) it uses this function. +(set! app.common.i18n/tr tr) diff --git a/frontend/src/app/util/object.cljc b/frontend/src/app/util/object.cljc index ee492dad00..723444b4d5 100644 --- a/frontend/src/app/util/object.cljc +++ b/frontend/src/app/util/object.cljc @@ -106,6 +106,11 @@ (identical? (.getPrototypeOf js/Object o) (.-prototype js/Object))))) +#?(:cljs + (defn stringify + [obj] + (js/JSON.stringify obj))) + ;; EXPERIMENTAL: unsafe, does not checks and not validates the input, ;; should be improved over time, for now it works for define a class ;; extending js/Error that is more than enought for a first, quick and @@ -163,14 +168,14 @@ bindings (->> properties (mapcat (fn [params] - (let [pname (c/get params :name) - get-expr (c/get params :get) - set-expr (c/get params :set) - fn-expr (c/get params :fn) - schema-n (c/get params :schema) - wrap (c/get params :wrap) - schema-1 (c/get params :schema-1) - this? (c/get params :this false) + (let [pname (c/get params :name) + get-expr (c/get params :get) + set-expr (c/get params :set) + fn-expr (c/get params :fn) + schema-n (c/get params :schema) + wrap (c/get params :wrap) + schema-1 (c/get params :schema-1) + this? (c/get params :this false) decode-expr (c/get params :decode/fn) diff --git a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs index 952e5a8288..63ffa83957 100644 --- a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs @@ -6,7 +6,7 @@ (ns frontend-tests.tokens.helpers.tokens (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.test-helpers.ids-map :as thi] [app.common.types.tokens-lib :as ctob])) @@ -20,7 +20,7 @@ (let [first-page-id (get-in file [:data :pages 0]) shape-id (thi/id shape-label) token (get-token file token-label) - applied-attributes (cft/attributes-map attributes token)] + applied-attributes (cfo/attributes-map attributes token)] (update-in file [:data :pages-index first-page-id :objects shape-id diff --git a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index c7f6a2e59c..94e61f382b 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -57,8 +57,7 @@ store (ths/setup-store file) tokens-lib (toht/get-tokens-lib file) set-a (ctob/get-set-by-name tokens-lib "Set A") - events [(dwtl/update-token-set (ctob/rename set-a "Set A updated") - "Set A updated")]] + events [(dwtl/rename-token-set set-a "Set A updated")]] (tohs/run-store-async store done events diff --git a/plugins/README.md b/plugins/README.md index 399aa37943..47fb5dbeeb 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -19,7 +19,7 @@ In the `apps` folder you'll find some examples that use the libraries mentioned - example-styles: to run this example you should run ``` -npm run start:styles-example +pnpm run start:styles-example ``` Open in your browser: `http://localhost:4202/` @@ -28,8 +28,8 @@ Open in your browser: `http://localhost:4202/` This guide will help you launch a Penpot plugin from the penpot-plugins repository. Before proceeding, ensure that you have Penpot running locally by following the [setup instructions](https://help.penpot.app/technical-guide/developer/devenv/). -In the terminal, navigate to the **penpot-plugins** repository and run `npm install` to install the required dependencies. -Then, run `npm start` to launch the plugins wrapper. +In the terminal, navigate to the **penpot-plugins** repository and run `pnpm install` to install the required dependencies. +Then, run `pnpm run start` to launch the plugins wrapper. After installing the dependencies, choose a plugin to launch. You can either run one of the provided examples or create your own (see "Creating a plugin from scratch" below). To launch a plugin, Open a new terminal tab and run the appropriate startup script for the chosen plugin. @@ -38,7 +38,7 @@ For instance, to launch the Contrast plugin, use the following command: ``` // for the contrast plugin -npm run start:plugin:contrast +pnpm run start:plugin:contrast ``` Finally, open in your browser the specific port. In this specific example would be `http://localhost:4302` @@ -47,23 +47,24 @@ A table listing the available plugins and their corresponding startup commands i ## Sample plugins -| Plugin | Description | PORT | Start command | Manifest URL | -| ----------------------- | ----------------------------------------------------------- | ---- | ------------------------------------- | ------------------------------------------ | -| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | npm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json | -| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | npm run start:plugin:contrast | http://localhost:4302/assets/manifest.json | -| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | npm run start:plugin:icons | http://localhost:4303/assets/manifest.json | -| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | npm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json | -| create-palette-plugin | Creates a board with all the palette colors | 4305 | npm run start:plugin:palette | http://localhost:4305/assets/manifest.json | -| table-plugin | Create or import table | 4306 | npm run start:table-plugin | http://localhost:4306/assets/manifest.json | -| rename-layers-plugin | Rename layers in bulk | 4307 | npm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json | -| colors-to-tokens-plugin | Generate tokens JSON file | 4308 | npm run start:plugin:colors-to-tokens | http://localhost:4308/assets/manifest.json | +| Plugin | Description | PORT | Start command | Manifest URL | +| ----------------------- | ----------------------------------------------------------- | ---- | -------------------------------------- | ------------------------------------------ | +| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | pnpm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json | +| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | pnpm run start:plugin:contrast | http://localhost:4302/assets/manifest.json | +| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | pnpm run start:plugin:icons | http://localhost:4303/assets/manifest.json | +| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | pnpm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json | +| create-palette-plugin | Creates a board with all the palette colors | 4305 | pnpm run start:plugin:palette | http://localhost:4305/assets/manifest.json | +| table-plugin | Create or import table | 4306 | pnpm run start:table-plugin | http://localhost:4306/assets/manifest.json | +| rename-layers-plugin | Rename layers in bulk | 4307 | pnpm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json | +| colors-to-tokens-plugin | Generate tokens JSON file | 4308 | pnpm run start:plugin:colors-to-tokens | http://localhost:4308/assets/manifest.json | +| poc-tokens-plugin | Sandbox plugin to test tokens functionality | 4309 | pnpm run start:plugin:poc-tokens | http://localhost:4309/assets/manifest.json | ## Web Apps -| App | Description | PORT | Start command | URL | -| --------------- | ----------------------------------------------------------------- | ---- | -------------------------------- | ---------------------- | -| plugins-runtime | Runtime for the plugins subsystem | 4200 | npm run start:app:runtime | | -| example-styles | Showcase of some of the Penpot styles that can be used in plugins | 4201 | npm run start:app:styles-example | http://localhost:4201/ | +| App | Description | PORT | Start command | URL | +| --------------- | ----------------------------------------------------------------- | ---- | --------------------------------- | ---------------------- | +| plugins-runtime | Runtime for the plugins subsystem | 4200 | pnpm run start:app:runtime | | +| example-styles | Showcase of some of the Penpot styles that can be used in plugins | 4201 | pnpm run start:app:styles-example | http://localhost:4201/ | ## Creating a plugin from scratch diff --git a/plugins/apps/poc-tokens-plugin/eslint.config.js b/plugins/apps/poc-tokens-plugin/eslint.config.js new file mode 100644 index 0000000000..7aa90c2ab0 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/eslint.config.js @@ -0,0 +1,51 @@ +import baseConfig from '../../eslint.config.js'; +import { compat } from '../../eslint.base.config.js'; + +export default [ + ...baseConfig, + ...compat + .config({ + extends: [ + 'plugin:@nx/angular', + 'plugin:@angular-eslint/template/process-inline-templates', + ], + }) + .map((config) => ({ + ...config, + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + }, + })), + ...compat + .config({ extends: ['plugin:@nx/angular-template'] }) + .map((config) => ({ + ...config, + files: ['**/*.html'], + rules: {}, + })), + { ignores: ['**/assets/*.js'] }, + { + languageOptions: { + parserOptions: { + project: './tsconfig.*?.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/plugins/apps/poc-tokens-plugin/project.json b/plugins/apps/poc-tokens-plugin/project.json new file mode 100644 index 0000000000..c2bc7cad81 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/project.json @@ -0,0 +1,79 @@ +{ + "name": "poc-tokens-plugin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/poc-tokens-plugin/src", + "tags": ["type:plugin"], + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/poc-tokens-plugin", + "index": "apps/poc-tokens-plugin/src/index.html", + "browser": "apps/poc-tokens-plugin/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/poc-tokens-plugin/tsconfig.app.json", + "assets": [ + "apps/poc-tokens-plugin/src/favicon.ico", + "apps/poc-tokens-plugin/src/assets" + ], + "styles": [ + "libs/plugins-styles/src/lib/styles.css", + "apps/poc-tokens-plugin/src/styles.css" + ], + "scripts": [], + "optimization": { + "scripts": true, + "styles": true, + "fonts": false + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production", + "dependsOn": ["buildPlugin"] + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "poc-tokens-plugin:build:production" + }, + "development": { + "buildTarget": "poc-tokens-plugin:build:development", + "port": 4309, + "host": "0.0.0.0" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "poc-tokens-plugin:build" + } + } + } +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.css b/plugins/apps/poc-tokens-plugin/src/app/app.component.css new file mode 100644 index 0000000000..9a64100273 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.css @@ -0,0 +1,127 @@ +/* @import "@penpot/plugin-styles/styles.css"; */ + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.title-l { + margin: var(--spacing-16) 0; +} + +.columns { + display: grid; + grid-template-columns: 50% 50%; + flex-grow: 1; + margin-block-end: var(--spacing-16); +} + +.panels { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 var(--spacing-8); +} + +.panel { + padding: var(--spacing-8); + display: flex; + flex-basis: 0; + flex-grow: 1; + flex-direction: column; + overflow: auto; +} + +.panel:not(:first-child) { + border-block-start: 1px solid var(--df-secondary); + padding-block-start: var(--spacing-16); +} + +.panel-heading, +.token-group { + display: flex; + flex-direction: row; + padding-inline-end: var(--spacing-8); +} + +.panel-heading p, +.token-group span { + flex-grow: 1; +} + +.panel-heading button, +.token-group button { + background: none; + padding: var(--spacing-4) calc(var(--spacing-12) / 2); +} + +.panel-heading button:focus, +.token-group button:focus { + padding: calc(var(--spacing-4) - 2px) calc(var(--spacing-12) / 2 - 2px); +} + +.panel-item button { + opacity: 0; + margin-inline-end: var(--spacing-8); + padding: var(--spacing-4) calc(var(--spacing-12) / 2); +} + +.panel-item button:hover { + opacity: 1; +} + +.panel-item button:focus { + opacity: 1; + padding: calc(var(--spacing-4) - 2px) calc(var(--spacing-12) / 2 - 2px); +} + +.panel ul { + /* flex-grow: 1; */ + overflow-y: auto; + padding-inline-end: var(--spacing-8); +} + +.panel-item { + display: flex; + flex-direction: row; +} + +.panel-item span { + flex-grow: 1; +} + +.set-item { + cursor: pointer; +} + +.set-item.selected { + background-color: var(--db-quaternary); +} + +.set-item:hover { + color: var(--da-primary); + background-color: var(--db-secondary); +} + +.token-group:not(:first-child) { + margin-top: var(--spacing-8); +} + +.token-group { + border-block-end: 1px solid var(--df-secondary); + text-transform: capitalize; +} + +.token-item { + cursor: pointer; +} + +.token-item:hover { + color: var(--da-primary); +} + +.buttons { + display: flex; + flex-direction: row-reverse; +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.html b/plugins/apps/poc-tokens-plugin/src/app/app.component.html new file mode 100644 index 0000000000..5be709cb57 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.html @@ -0,0 +1,144 @@ +
+

Design tokens plugin POC

+ +
+
+
+
+

THEMES

+ +
+ +
    + @for (theme of themes; track theme.id) { +
  • + {{ theme.group }} / {{ theme.name }} + + +
    + +
    +
  • + } +
+
+ +
+
+

SETS

+ +
+ +
    + @for (set of sets; track set.id) { +
  • + + {{ set.name }} + + + +
    + +
    +
  • + } +
+
+
+
+
+

TOKENS

+ +
    + @for (group of tokenGroups; track group[0]) { +
  • + {{ group[0] }} + +
  • + @for (token of group[1]; track token.id) { +
  • + {{ token.name }} + + +
  • + } + } +
+
+
+
+ +
+ +
+
diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.ts b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts new file mode 100644 index 0000000000..87ce29b22d --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts @@ -0,0 +1,290 @@ +import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { fromEvent, map, filter, take, merge } from 'rxjs'; +import { PluginMessageEvent, PluginUIEvent } from '../model'; + +type TokenTheme = { + id: string; + name: string; + group: string; + description: string; + active: boolean; +}; + +type TokenSet = { + id: string; + name: string; + description: string; + active: boolean; +}; + +type Token = { + id: string; + name: string; + description: string; +}; + +type TokensGroup = [string, Token[]]; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.css', + host: { + '[attr.data-theme]': 'theme()', + }, +}) +export class AppComponent { + public route = inject(ActivatedRoute); + + public messages$ = fromEvent>( + window, + 'message', + ); + + public initialTheme$ = this.route.queryParamMap.pipe( + map((params) => params.get('theme')), + filter((theme) => !!theme), + take(1), + ); + + public theme = toSignal( + merge( + this.initialTheme$, + this.messages$.pipe( + filter((event) => event.data.type === 'theme'), + map((event) => { + return event.data.content; + }), + ), + ), + ); + + public themes: TokenTheme[] = []; + public sets: TokenSet[] = []; + public tokenGroups: TokensGroup[] = []; + public currentSetId: string | undefined = undefined; + + constructor() { + window.addEventListener('message', (event) => { + if (event.data.type === 'set-themes') { + this.#setThemes(event.data.themesData); + } else if (event.data.type === 'set-sets') { + this.#setSets(event.data.setsData); + } else if (event.data.type === 'set-tokens') { + this.#setTokens(event.data.tokenGroupsData); + } + }); + } + + loadLibrary() { + this.#sendMessage({ type: 'load-library' }); + } + + loadTokens(setId: string) { + this.currentSetId = setId; + this.#sendMessage({ type: 'load-tokens', setId }); + } + + addTheme() { + this.#sendMessage({ + type: 'add-theme', + themeGroup: this.#randomString(), + themeName: this.#randomString(), + }); + } + + addSet() { + this.#sendMessage({ type: 'add-set', setName: this.#randomString() }); + } + + addToken(tokenType: string) { + let tokenValue; + switch (tokenType) { + case 'borderRadius': + tokenValue = '25'; + break; + case 'shadow': + tokenValue = [ + { + color: '#123456', + inset: 'false', + offsetX: '6', + offsetY: '6', + spread: '0', + blur: '4', + }, + ]; + break; + case 'color': + tokenValue = '#fabada'; + break; + case 'dimension': + tokenValue = '100'; + break; + case 'fontFamilies': + tokenValue = ['Source Sans Pro', 'Sans serif']; + break; + case 'fontSizes': + tokenValue = '24'; + break; + case 'fontWeights': + tokenValue = 'bold'; + break; + case 'letterSpacing': + tokenValue = '0.5'; + break; + case 'number': + tokenValue = '33'; + break; + case 'opacity': + tokenValue = '0.6'; + break; + case 'rotation': + tokenValue = '45'; + break; + case 'sizing': + tokenValue = '200'; + break; + case 'spacing': + tokenValue = '16'; + break; + case 'borderWidth': + tokenValue = '3'; + break; + case 'textCase': + tokenValue = 'lowercase'; + break; + case 'textDecoration': + tokenValue = 'underline'; + break; + case 'typography': + tokenValue = { + fontFamilies: ['Acme', 'Arial', 'Sans Serif'], + fontSizes: '36', + letterSpacing: '0.8', + textCase: 'uppercase', + textDecoration: 'none', + fontWeights: '600', + lineHeight: '1.5', + }; + break; + } + + if (this.currentSetId && tokenValue) { + this.#sendMessage({ + type: 'add-token', + setId: this.currentSetId, + tokenType, + tokenName: this.#randomString(), + tokenValue, + }); + } else { + console.log('Invalid token type'); + } + } + + renameTheme(themeId: string, themeName: string) { + const newName = prompt('Rename theme', themeName); + if (newName && newName !== '') { + this.#sendMessage({ type: 'rename-theme', themeId, newName }); + } + } + + renameSet(setId: string, setName: string) { + const newName = prompt('Rename set', setName); + if (newName && newName !== '') { + this.#sendMessage({ type: 'rename-set', setId, newName }); + } + } + + renameToken(tokenId: string, tokenName: string) { + const newName = prompt('Rename token', tokenName); + if (this.currentSetId && newName && newName !== '') { + this.#sendMessage({ + type: 'rename-token', + setId: this.currentSetId, + tokenId, + newName, + }); + } + } + + deleteTheme(themeId: string) { + this.#sendMessage({ type: 'delete-theme', themeId }); + } + + deleteSet(setId: string) { + this.#sendMessage({ type: 'delete-set', setId }); + } + + deleteToken(tokenId: string) { + if (this.currentSetId) { + this.#sendMessage({ + type: 'delete-token', + setId: this.currentSetId, + tokenId, + }); + } + } + + isThemeActive(themeId: string) { + for (const theme of this.themes) { + if (theme.id === themeId) { + return theme.active; + } + } + return false; + } + + toggleTheme(themeId: string) { + this.#sendMessage({ type: 'toggle-theme', themeId }); + } + + isSetActive(setId: string) { + for (const set of this.sets) { + if (set.id === setId) { + return set.active; + } + } + return false; + } + + toggleSet(setId: string) { + this.#sendMessage({ type: 'toggle-set', setId }); + } + + applyToken(tokenId: string) { + if (this.currentSetId) { + this.#sendMessage({ + type: 'apply-token', + setId: this.currentSetId, + tokenId, + // properties: ['strokeColor'] // Uncomment to choose attribute to apply + }); // (incompatible attributes will have no effect) + } + } + + #sendMessage(message: PluginUIEvent) { + parent.postMessage(message, '*'); + } + + #setThemes(themes: TokenTheme[]) { + this.themes = themes; + } + + #setSets(sets: TokenSet[]) { + this.sets = sets; + } + + #setTokens(tokenGroups: TokensGroup[]) { + this.tokenGroups = tokenGroups; + } + + #randomString() { + // Generate a big random number and convert it to string using base 36 + // (the number of letters in the ascii alphabet) + return Math.floor(Math.random() * Date.now()).toString(36); + } +} diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.config.ts b/plugins/apps/poc-tokens-plugin/src/app/app.config.ts new file mode 100644 index 0000000000..fb93f472fd --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.config.ts @@ -0,0 +1,11 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + ], +}; diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts b/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts new file mode 100644 index 0000000000..dc39edb5f2 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/plugins/apps/poc-tokens-plugin/src/assets/CORS b/plugins/apps/poc-tokens-plugin/src/assets/CORS new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/CORS @@ -0,0 +1 @@ +* diff --git a/plugins/apps/poc-tokens-plugin/src/assets/_headers b/plugins/apps/poc-tokens-plugin/src/assets/_headers new file mode 100644 index 0000000000..c776a4792b --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/_headers @@ -0,0 +1,2 @@ +/* + Access-Control-Allow-Origin: * diff --git a/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico b/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico new file mode 100644 index 0000000000..fc5e208af4 Binary files /dev/null and b/plugins/apps/poc-tokens-plugin/src/assets/favicon.ico differ diff --git a/plugins/apps/poc-tokens-plugin/src/assets/icon.png b/plugins/apps/poc-tokens-plugin/src/assets/icon.png new file mode 100644 index 0000000000..cf045fb5e6 Binary files /dev/null and b/plugins/apps/poc-tokens-plugin/src/assets/icon.png differ diff --git a/plugins/apps/poc-tokens-plugin/src/assets/manifest.json b/plugins/apps/poc-tokens-plugin/src/assets/manifest.json new file mode 100644 index 0000000000..540167f2f9 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/assets/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Design tokens plugin POC", + "description": "This is a plugin to try Design Tokens in Penpot API", + "code": "/assets/plugin.js", + "permissions": [ + "page:read", + "content:read", + "file:read", + "selection:read", + "content:write", + "library:read", + "library:write" + ] +} diff --git a/plugins/apps/poc-tokens-plugin/src/index.html b/plugins/apps/poc-tokens-plugin/src/index.html new file mode 100644 index 0000000000..c285210e33 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular example plugin + + + + + + + + diff --git a/plugins/apps/poc-tokens-plugin/src/main.ts b/plugins/apps/poc-tokens-plugin/src/main.ts new file mode 100644 index 0000000000..8882c4517f --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/plugins/apps/poc-tokens-plugin/src/model.ts b/plugins/apps/poc-tokens-plugin/src/model.ts new file mode 100644 index 0000000000..9509e75487 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/model.ts @@ -0,0 +1,112 @@ +import { TokenProperty } from '@penpot/plugin-types'; + +/** + * This file contains the typescript interfaces for the plugin events. + */ + +// Events sent from the ui to the plugin + +export interface LoadLibraryEvent { + type: 'load-library'; +} + +export interface LoadTokensEvent { + type: 'load-tokens'; + setId: string; +} + +export interface AddThemeEvent { + type: 'add-theme'; + themeGroup: string; + themeName: string; +} + +export interface AddSetEvent { + type: 'add-set'; + setName: string; +} + +export interface AddTokenEvent { + type: 'add-token'; + setId: string; + tokenType: string; + tokenName: string; + tokenValue: unknown; +} + +export interface RenameThemeEvent { + type: 'rename-theme'; + themeId: string; + newName: string; +} + +export interface RenameSetEvent { + type: 'rename-set'; + setId: string; + newName: string; +} + +export interface RenameTokenEvent { + type: 'rename-token'; + setId: string; + tokenId: string; + newName: string; +} + +export interface DeleteThemeEvent { + type: 'delete-theme'; + themeId: string; +} + +export interface DeleteSetEvent { + type: 'delete-set'; + setId: string; +} + +export interface DeleteTokenEvent { + type: 'delete-token'; + setId: string; + tokenId: string; +} + +export interface ToggleThemeEvent { + type: 'toggle-theme'; + themeId: string; +} + +export interface ToggleSetEvent { + type: 'toggle-set'; + setId: string; +} + +export interface ApplyTokenEvent { + type: 'apply-token'; + setId: string; + tokenId: string; + properties?: TokenProperty[]; +} + +export type PluginUIEvent = + | LoadLibraryEvent + | LoadTokensEvent + | AddThemeEvent + | AddSetEvent + | AddTokenEvent + | RenameThemeEvent + | RenameSetEvent + | RenameTokenEvent + | DeleteThemeEvent + | DeleteSetEvent + | DeleteTokenEvent + | ToggleThemeEvent + | ToggleSetEvent + | ApplyTokenEvent; + +// Events sent from the plugin to the ui + +export interface ThemePluginEvent { + type: 'theme'; + content: string; +} + +export type PluginMessageEvent = ThemePluginEvent; diff --git a/plugins/apps/poc-tokens-plugin/src/plugin.ts b/plugins/apps/poc-tokens-plugin/src/plugin.ts new file mode 100644 index 0000000000..28fe7e8d14 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/plugin.ts @@ -0,0 +1,262 @@ +import type { PluginMessageEvent, PluginUIEvent } from './model.js'; +import { + TokenType, + TokenProperty, + TokenValueString, +} from '@penpot/plugin-types'; + +penpot.ui.open('Design Tokens test', `?theme=${penpot.theme}`, { + width: 1000, + height: 800, +}); + +penpot.on('themechange', (theme) => { + sendMessage({ type: 'theme', content: theme }); +}); + +penpot.ui.onMessage(async (message) => { + if (message.type === 'load-library') { + loadLibrary(); + } else if (message.type === 'load-tokens') { + loadTokens(message.setId); + } else if (message.type === 'add-theme') { + addTheme(message.themeGroup, message.themeName); + } else if (message.type === 'add-set') { + addSet(message.setName); + } else if (message.type === 'add-token') { + addToken( + message.setId, + message.tokenType, + message.tokenName, + message.tokenValue, + ); + } else if (message.type === 'rename-theme') { + renameTheme(message.themeId, message.newName); + } else if (message.type === 'rename-set') { + renameSet(message.setId, message.newName); + } else if (message.type === 'rename-token') { + renameToken(message.setId, message.tokenId, message.newName); + } else if (message.type === 'delete-theme') { + deleteTheme(message.themeId); + } else if (message.type === 'delete-set') { + deleteSet(message.setId); + } else if (message.type === 'delete-token') { + deleteToken(message.setId, message.tokenId); + } else if (message.type === 'toggle-theme') { + toggleTheme(message.themeId); + } else if (message.type === 'toggle-set') { + toggleSet(message.setId); + } else if (message.type === 'apply-token') { + applyToken(message.setId, message.tokenId, message.properties); + } +}); + +function sendMessage(message: PluginMessageEvent) { + penpot.ui.sendMessage(message); +} + +function loadLibrary() { + const tokensCatalog = penpot.library.local.tokens; + + const themes = tokensCatalog.themes; + + const themesData = themes.map((theme) => { + return { + id: theme.id, + group: theme.group, + name: theme.name, + active: theme.active, + }; + }); + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-themes', + themesData, + }); + + const sets = tokensCatalog.sets; + + const setsData = sets.map((set) => { + return { + id: set.id, + name: set.name, + active: set.active, + }; + }); + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-sets', + setsData, + }); +} + +function loadTokens(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const tokensByType = set?.tokensByType; + + const tokenGroupsData = []; + if (tokensByType) { + for (const group of tokensByType) { + const type = group[0]; + const tokens = group[1]; + tokenGroupsData.push([ + type, + tokens.map((token) => { + return { + id: token.id, + name: token.name, + description: token.description, + }; + }), + ]); + } + + penpot.ui.sendMessage({ + source: 'penpot', + type: 'set-tokens', + tokenGroupsData, + }); + } +} + +function addTheme(themeGroup: string, themeName: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.addTheme({ group: themeGroup, name: themeName }); + if (theme) { + loadLibrary(); + } +} + +function addSet(setName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.addSet({ name: setName }); + if (set) { + loadLibrary(); + } +} + +function addToken( + setId: string, + tokenType: string, + tokenName: string, + tokenValue: unknown, +) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.addToken({ + type: tokenType as TokenType, + name: tokenName, + value: tokenValue as TokenValueString, + }); + if (token) { + // TODO: remove this timeout when styleDictionary is replaced + // with tokenScript and the token validation is syncrhronous. + setTimeout(() => { + loadTokens(setId); + }, 0); + } +} + +function renameTheme(themeId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.name = newName; + loadLibrary(); + } +} + +function renameSet(setId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.name = newName; + loadLibrary(); + } +} + +function renameToken(setId: string, tokenId: string, newName: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + if (token) { + token.name = newName; + // TODO: remove this timeout when styleDictionary is replaced + // with tokenScript and the token validation is syncrhronous. + setTimeout(() => { + loadTokens(setId); + }, 0); + } +} + +function deleteTheme(themeId: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.remove(); + loadLibrary(); + } +} + +function deleteSet(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.remove(); + loadLibrary(); + } +} + +function deleteToken(setId: string, tokenId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + if (token) { + token.remove(); + loadTokens(setId); + } +} + +function toggleTheme(themeId: string) { + const tokensCatalog = penpot.library.local.tokens; + const theme = tokensCatalog?.getThemeById(themeId); + if (theme) { + theme.toggleActive(); + loadLibrary(); + } +} + +function toggleSet(setId: string) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + if (set) { + set.toggleActive(); + loadLibrary(); + } +} + +function applyToken( + setId: string, + tokenId: string, + properties: TokenProperty[] | undefined, +) { + const tokensCatalog = penpot.library.local.tokens; + const set = tokensCatalog?.getSetById(setId); + const token = set?.getTokenById(tokenId); + + if (token) { + token.applyToSelected(properties); + } + + // Alternatve way + // + // const selection = penpot.selection; + // if (token && selection) { + // for (const shape of selection) { + // shape.applyToken(token, properties); + // } + // } +} diff --git a/plugins/apps/poc-tokens-plugin/src/styles.css b/plugins/apps/poc-tokens-plugin/src/styles.css new file mode 100644 index 0000000000..007341e2f7 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/src/styles.css @@ -0,0 +1,23 @@ +/* @import "@penpot/plugin-styles/styles.css"; */ + +html { + height: 100%; +} + +body { + height: 100%; + line-height: 1.5; + padding: 10px; +} + +ul { + margin-block-start: var(--spacing-12); +} + +.title-l { + text-align: center; +} + +.headline-l { + margin-block-start: var(--spacing-8); +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.app.json b/plugins/apps/poc-tokens-plugin/tsconfig.app.json new file mode 100644 index 0000000000..936913d9af --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.editor.json b/plugins/apps/poc-tokens-plugin/tsconfig.editor.json new file mode 100644 index 0000000000..b927bb69fc --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.editor.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.json b/plugins/apps/poc-tokens-plugin/tsconfig.json new file mode 100644 index 0000000000..4c48587cfe --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.editor.json" + }, + { + "path": "./tsconfig.plugin.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json b/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json new file mode 100644 index 0000000000..961987f7a1 --- /dev/null +++ b/plugins/apps/poc-tokens-plugin/tsconfig.plugin.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "files": ["src/plugin.ts"], + "include": ["../../libs/plugin-types/index.d.ts"] +} diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index ff56cb2490..4b2fbe1c96 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2533,6 +2533,13 @@ export interface Library extends PluginData { */ readonly components: LibraryComponent[]; + /** + * A catalog of Design Tokens in the library. + * + * See `TokenCatalog` type to see usage. + */ + readonly tokens: TokenCatalog; + /** * Creates a new color element in the library. * @return Returns a new `LibraryColor` object representing the created color element. @@ -2800,9 +2807,9 @@ export interface LibraryTypography extends LibraryElement { fontId: string; /** - * The font family of the typography element. + * The font families of the typography element. */ - fontFamily: string; + fontFamilies: string; /** * The unique identifier of the font variant used in the typography element. @@ -3728,6 +3735,17 @@ export interface ShapeBase extends PluginData { */ setParentIndex(index: number): void; + /** + * The design tokens applied to this shape. + * It's a map property name -> token name. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + readonly tokens: { [property: string]: string }; + /** * @return Returns true if the current shape is inside a component instance */ @@ -3892,6 +3910,19 @@ export interface ShapeBase extends PluginData { */ removeInteraction(interaction: Interaction): void; + /** + * Applies one design token to one or more properties of the shape. + * @param token is the Token to apply + * @param properties an optional list of property names. If omitted, the + * default properties will be applied. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + applyToken(token: Token, properties: TokenProperty[] | undefined): void; + /** * Creates a clone of the shape. * @return Returns a new instance of the shape with identical properties. @@ -4279,6 +4310,1051 @@ export type TrackType = 'flex' | 'fixed' | 'percent' | 'auto'; */ export type Trigger = 'click' | 'mouse-enter' | 'mouse-leave' | 'after-delay'; +/** + * Represents the base properties and methods of a Design Token in Penpot, shared by + * all token types. + */ +export interface TokenBase { + /** + * The unique identifier for this token, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * The name of the token. It may include a group path separated by `.`. + */ + name: string; + + /** + * An optional description text. + */ + description: string; + + /** + * Adds to the set that contains this Token a new one equal to this one + * but with a new id. + */ + duplicate(): Token; + + /** + * Removes this token from the catalog. + * + * It will NOT be unapplied from any shape, since there may be other tokens + * with the same name. + */ + remove(): void; + + /** + * Applies this token to one or more properties of the given shapes. + * @param shapes is an array of shapes to apply it. + * @param properties an optional list of property names. If omitted, the + * default properties will be applied. + * + * NOTE that the tokens application is by name and not by id. If there exist + * several tokens with the same name in different sets, the actual token applied + * and the value set to the attributes will depend on which sets are active + * (and will change if different sets or themes are activated later). + */ + applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void; + + /** + * Applies this token to the currently selected shapes. + * + * Parameters and warnings are the same as above. + */ + applyToSelected(properties: TokenProperty[] | undefined): void; +} + +/** + * Represents a token of type BorderRadius. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenBorderRadius extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'borderRadius'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/* + * The value of a TokenShadow in its composite form. + */ +export interface TokenShadowValue { + /** + * The color as a string (e.g. "#FF5733"). + */ + color: string; + + /** + * If the shadow is inset or drop. + */ + inset: boolean; + + /** + * The horizontal offset of the shadow in pixels. + */ + offsetX: number; + + /** + * The vertical offset of the shadow in pixels. + */ + offsetY: number; + + /** + * The spread distance of the shadow in pixels. + */ + spread: number; + + /** + * The amount of blur to apply to the shadow. + */ + blur: number; +} + +/* + * The value of a TokenShadow in its composite of strings form. + */ +export interface TokenShadowValueString { + /** + * The color as a string (e.g. "#FF5733"), or a reference + * to a color token. + */ + color: string; + + /** + * If the shadow is inset or drop, or a reference of a + * boolean token. + */ + inset: string; + + /** + * The horizontal offset of the shadow in pixels, or a reference + * to a number token. + */ + offsetX: string; + + /** + * The vertical offset of the shadow in pixels, or a reference + * to a number token. + */ + offsetY: string; + + /** + * The spread distance of the shadow in pixels, or a reference + * to a number token. + */ + spread: string; + + /** + * The amount of blur to apply to the shadow, or a reference + * to a number token. + */ + blur: string; +} + +/** + * Represents a token of type Shadow. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenShadow extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'shadow'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or else + * an array of TokenShadowValueString. + */ + value: string | TokenShadowValueString[]; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's an array of TokenShadowValue, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: TokenShadowValue[] | undefined; +} + +/** + * Represents a token of type Color. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenColor extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'color'; + + /** + * The value as defined in the token itself. + * It's a rgb color or a reference. + */ + value: string; + + /** + * The value as defined in the token itself. + * It's a rgb color or a reference. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type Dimension. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenDimension extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'dimension'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type FontFamilies. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontFamilies extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontFamilies'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or else + * an array of strings with one or more font families (each family + * is an item in the array). + */ + value: string | string[]; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's an array of strings with one or more font families, + * or undefined if no value has been found in active sets. + */ + readonly resolvedValue: string[] | undefined; +} + +/** + * Represents a token of type FontSizes. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontSizes extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontSizes'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type FontWeights. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenFontWeights extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'fontWeights'; + + /** + * The value as defined in the token itself. + * It's a weight string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a weight string ("bold", "strong", etc.), or undefined if no value has + * been found in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type LetterSpacing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenLetterSpacing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'letterSpacing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Number. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenNumber extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'number'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Opacity. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenOpacity extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'opacity'; + + /** + * The value as defined in the token itself. + * It's a number between 0 and 1 or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number between 0 and 1, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Rotation. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenRotation extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'rotation'; + + /** + * The value as defined in the token itself. + * It's a number in degrees or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number in degrees, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Sizing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenSizing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'sizing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type Spacing. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenSpacing extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'spacing'; + + /** + * The value as defined in the token itself. + * It's a number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type BorderWidth. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenBorderWidth extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'borderWidth'; + + /** + * The value as defined in the token itself. + * It's a positive number or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a positive number, or undefined if no value has been found in active sets. + */ + readonly resolvedValue: number | undefined; +} + +/** + * Represents a token of type TextCase. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTextCase extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'textCase'; + + /** + * The value as defined in the token itself. + * It's a case string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a case string ("none", "uppercase", "lowercase", "capitalize"), or + * undefined if no value has been found in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/** + * Represents a token of type Decoration. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTextDecoration extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'textDecoration'; + + /** + * The value as defined in the token itself. + * It's a decoration string or a reference. + */ + value: string; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a decoration string, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: string | undefined; +} + +/* + * The value of a TokenTypography in its composite form. + */ +export interface TokenTypographyValue { + /** + * The letter spacing, as a number. + */ + letterSpacing: number; + + /** + * The list of font families. + */ + fontFamilies: string[]; + + /** + * The font size, as a positive number. + */ + fontSizes: number; + + /** + * The font weight, as a weight string ("bold", "strong", etc.). + */ + fontWeights: string; + + /** + * The line height, as a number. + */ + lineHeight: number; + + /** + * The text case as a string ("none", "uppercase", "lowercase" "capitalize"). + */ + textCase: string; + + /** + * The text decoration as a string ("none", "underline", "strike-through"). + */ + textDecoration: string; +} + +/* + * The value of a TokenTypography in its composite of strings form. + */ +export interface TokenTypographyValueString { + /** + * The letter spacing, as a number, or a reference to a TokenLetterSpacing. + */ + letterSpacing: string; + + /** + * The list of font families, or a reference to a TokenFontFamilies. + */ + fontFamilies: string | string[]; + + /** + * The font size, as a positive number, or a reference to a TokenFontSizes. + */ + fontSizes: string; + + /** + * The font weight, as a weight string ("bold", "strong", etc.), or a + * reference to a TokenFontWeights. + */ + fontWeight: string; + + /** + * The line height, as a number. Note that there not exists an individual + * token type line height, only part of a Typography token. If you need to + * put here a reference, use a NumberToken. + */ + lineHeight: string; + + /** + * The text case as a string ("none", "uppercase", "lowercase" "capitalize"), + * or a reference to a TokenTextCase. + */ + textCase: string; + + /** + * The text decoration as a string ("none", "underline", "strike-through"), + * or a reference to a TokenTextDecoration. + */ + textDecoration: string; +} + +/** + * Represents a token of type Typography. + * This interface extends `TokenBase` and specifies the data type of the value. + */ +export interface TokenTypography extends TokenBase { + /** + * The type of the token. + */ + readonly type: 'typography'; + + /** + * The value as defined in the token itself. + * It may be a string with a reference to other token, or a + * TokenTypographyValueString. + */ + value: string | TokenTypographyValueString; + + /** + * The value calculated by finding all tokens with the same name in active sets + * and resolving the references. + * + * It's a TokenTypographyValue, or undefined if no value has been found + * in active sets. + */ + readonly resolvedValue: TokenTypographyValue[] | undefined; +} + +/** + * Any possible type of value field in a token. + */ +export type TokenValueString = + | TokenShadowValueString + | TokenTypographyValueString + | string + | string[]; + +/** + * The supported Design Tokens in Penpot. + */ +export type Token = + | TokenBorderRadius + | TokenShadow + | TokenColor + | TokenDimension + | TokenFontFamilies + | TokenFontSizes + | TokenFontWeights + | TokenLetterSpacing + | TokenNumber + | TokenOpacity + | TokenRotation + | TokenSizing + | TokenSpacing + | TokenBorderWidth + | TokenTextCase + | TokenTextDecoration + | TokenTypography; + +/** + * The collection of all tokens in a Penpot file's library. + * + * Tokens are contained in sets, that can be marked as active + * or inactive to control the resolved value of the tokens. + * + * The active status of sets can be handled by presets named + * Themes. + */ +export interface TokenCatalog { + /** + * The list of themes in this catalog, in creation order. + */ + readonly themes: TokenTheme[]; + + /** + * The list of sets in this catalog, in the order defined + * by the user. The order is important because then same token name + * exists in several active sets, the latter has precedence. + */ + readonly sets: TokenSet[]; + + /** + * Creates a new TokenTheme and adds it to the catalog. + * @param group The group name of the theme (can be empty string). + * @param name The name of the theme (required) + * @return Returns the created TokenTheme. + */ + addTheme({ group, name }: { group: string; name: string }): TokenTheme; + + /** + * Creates a new TokenSet and adds it to the catalog. + * @param name The name of the set (required). It may contain + * a group path, separated by `/`. + * @return Returns the created TokenSet. + */ + addSet({ name }: { name: string }): TokenSet; + + /** + * Retrieves a theme. + * @param id the id of the theme. + * @returns Returns the theme or undefined if not found. + */ + getThemeById(id: string): TokenTheme | undefined; + + /** + * Retrieves a set. + * @param id the id of the set. + * @returns Returns the set or undefined if not found. + */ + getSetById(id: string): TokenSet | undefined; +} + +/** + * A collection of Design Tokens. + * + * Inside a set, tokens have an unique name, that will designate + * what token to use if the name is applied to a shape and this + * set is active. + */ +export interface TokenSet { + /** + * The unique identifier for this set, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * The name of the set. It may include a group path separated by `/`. + */ + name: string; + + /** + * Indicates if the set is currently active. + */ + active: boolean; + + /** + * The tokens contained in this set, in alphabetical order. + */ + readonly tokens: Token[]; + + /** + * The tokens contained in this set, grouped by type. + */ + readonly tokensByType: [string, Token[]][]; + + /** + * Toggles the active status of this set. + */ + toggleActive(): void; + + /** + * Retrieves a token. + * @param id the id of the token. + * @returns Returns the token or undefined if not found. + */ + getTokenById(id: string): Token | undefined; + + /** + * Creates a new Token and adds it to the set. + * @param type Thetype of token. + * @param name The name of the token (required). It may contain + * a group path, separated by `.`. + * @param value The value of the token (required), in the string form. + * @return Returns the created Token. + */ + addToken({ + type, + name, + value, + }: { + type: TokenType; + name: string; + value: TokenValueString; + }): Token; + + /** + * Adds to the catalog a new TokenSet equal to this one but with a new id. + */ + duplicate(): TokenSet; + + /** + * Removes this set from the catalog. + */ + remove(): void; +} + +/** + * A preset of active TokenSets. + * + * A theme contains a list of references to TokenSets. When the theme + * is activated, it sets are activated too. This will not deactivate + * sets that are _not_ in this theme, because they may have been + * activated by other themes. + * + * Themes may be gruped. At any time only one of the themes in a group + * may be active. But there may be active themes in other groups. This + * allows to define multiple "axis" for theming (e.g. color scheme, + * density or brand). + * + * When a TokenSet is activated or deactivated directly, all themes + * are disabled (indicating that now there is a "custom" manual theme + * active). + */ +export interface TokenTheme { + /** + * The unique identifier for this theme, used only internally inside Penpot. + * This one is not exported or synced with external Design Token sources. + */ + readonly id: string; + + /** + * Optional identifier that may exists if the theme was imported from an + * external tool that uses ids in the json file. + */ + readonly externalId: string | undefined; + + /** + * The group name of the theme. Can be empt string. + */ + group: string; + + /** + * The name of the theme. + */ + name: string; + + /** + * Indicates if the theme is currently active. + */ + active: boolean; + + /** + * Toggles the active status of this theme. + */ + toggleActive(): void; + + /** + * The sets that will be activated if this theme is activated. + */ + activeSets: TokenSet[]; + + /** + * Adds a set to the list of the theme. + */ + addSet(tokenSet: TokenSet): void; + + /** + * Removes a set from the list of the theme. + */ + removeSet(tokenSet: TokenSet): void; + + /** + * Adds to the catalog a new TokenTheme equal to this one but with a new id. + */ + duplicate(): TokenTheme; + + /** + * Removes this theme from the catalog. + */ + remove(): void; +} + +/** + * The properties that a BorderRadius token can be applied to. + */ +type TokenBorderRadiusProps = 'r1' | 'r2' | 'r3' | 'r4'; + +/** + * The properties that a Shadow token can be applied to. + */ +type TokenShadowProps = 'shadow'; + +/** + * The properties that a Color token can be applied to. + */ +type TokenColorProps = 'fill' | 'strokeColor'; + +/** + * The properties that a Dimension token can be applied to. + */ +type TokenDimensionProps = + // Axis + | 'x' + | 'y' + + // Stroke width + | 'stroke-width'; + +/** + * The properties that a FontFamilies token can be applied to. + */ +type TokenFontFamiliesProps = 'font-families'; + +/** + * The properties that a FontSizes token can be applied to. + */ +type TokenFontSizesProps = 'font-size'; + +/** + * The properties that a FontWeight token can be applied to. + */ +type TokenFontWeightProps = 'font-weight'; + +/** + * The properties that a LetterSpacing token can be applied to. + */ +type TokenLetterSpacingProps = 'letter-spacing'; + +/** + * The properties that a Number token can be applied to. + */ +type TokenNumberProps = 'rotation' | 'line-height'; + +/** + * The properties that an Opacity token can be applied to. + */ +type TokenOpacityProps = 'opacity'; + +/** + * The properties that a Sizing token can be applied to. + */ +type TokenSizingProps = + // Size + | 'width' + | 'height' + + // Layout + | 'layout-item-min-w' + | 'layout-item-max-w' + | 'layout-item-min-h' + | 'layout-item-max-h'; + +/** + * The properties that a Spacing token can be applied to. + */ +type TokenSpacingProps = + // Spacing / Gap + | 'row-gap' + | 'column-gap' + + // Spacing / Padding + | 'p1' + | 'p2' + | 'p3' + | 'p4' + + // Spacing / Margin + | 'm1' + | 'm2' + | 'm3' + | 'm4'; + +/** + * The properties that a BorderWidth token can be applied to. + */ +type TokenBorderWidthProps = 'stroke-width'; + +/** + * The properties that a TextCase token can be applied to. + */ +type TokenTextCaseProps = 'text-case'; + +/** + * The properties that a TextDecoration token can be applied to. + */ +type TokenTextDecorationProps = 'text-decoration'; + +/** + * The properties that a Typography token can be applied to. + */ +type TokenTypographyProps = 'typography'; + +/** + * All the properties that a token can be applied to. + * Not always correspond to Shape properties. For example, + * `fill` property applies to `fillColor` of the first fill + * of the shape. + * + */ +export type TokenProperty = + | 'all' + | TokenBorderRadiusProps + | TokenShadowProps + | TokenColorProps + | TokenDimensionProps + | TokenFontFamiliesProps + | TokenFontSizesProps + | TokenFontWeightProps + | TokenLetterSpacingProps + | TokenNumberProps + | TokenOpacityProps + | TokenSizingProps + | TokenSpacingProps + | TokenBorderWidthProps + | TokenTextCaseProps + | TokenTextDecorationProps + | TokenTypographyProps; + +/** + * The supported types of Design Tokens in Penpot. + */ +export type TokenType = + | 'borderRadius' + | 'shadow' + | 'color' + | 'dimension' + | 'fontFamilies' + | 'fontSizes' + | 'fontWeights' + | 'letterSpacing' + | 'number' + | 'opacity' + | 'rotation' + | 'sizing' + | 'spacing' + | 'borderWidth' + | 'textCase' + | 'textDecoration' + | 'typography'; + /** * Represents a user in Penpot. */ diff --git a/plugins/package.json b/plugins/package.json index 722feae2a5..50a9e29f7b 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -16,6 +16,7 @@ "start:plugin:table": "nx run table-plugin:init", "start:plugin:renamelayers": "nx run rename-layers-plugin:init", "start:plugin:colors-to-tokens": "nx run colors-to-tokens-plugin:init", + "start:plugin:poc-tokens": "nx run poc-tokens-plugin:init", "build": "nx build plugins-runtime --emptyOutDir=true", "build:plugins": "nx run-many -t build --parallel -p tag:type:plugin --exclude=poc-state-plugin", "build:styles-example": "nx run example-styles:build",