From 5b5f22a8c6b1320c80bc109a301bc626f22deee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Mon, 9 Feb 2026 14:18:31 +0100 Subject: [PATCH] :tada: Add tokens to Penpot Plugins API (#7756) * :tada: Add tokens to plugins API documentation And add poc plugin example * :books: Document better the tokens value in plugins API * :wrench: Refactor token validation schemas * :wrench: Use automatic validation in token proxies * :wrench: Use schemas to validate token creation * :wrench: Use multi schema for token value * :wrench: Use schema in token api methods * :bug: Fix review comments --------- Co-authored-by: Andrey Antukh --- CHANGES.md | 1 + common/src/app/common/files/changes.cljc | 3 +- common/src/app/common/files/tokens.cljc | 272 ++++- common/src/app/common/i18n.cljc | 15 + common/src/app/common/logic/shapes.cljc | 2 +- common/src/app/common/schema.cljc | 59 + common/src/app/common/types/token.cljc | 448 +++---- common/src/app/common/types/tokens_lib.cljc | 94 +- .../test/common_tests/files/tokens_test.cljc | 49 +- .../common_tests/logic/token_apply_test.cljc | 24 +- .../test/common_tests/types/token_test.cljc | 19 +- .../common_tests/types/tokens_lib_test.cljc | 22 +- .../src/app/main/data/style_dictionary.cljs | 12 +- .../data/workspace/tokens/application.cljs | 10 +- .../app/main/data/workspace/tokens/color.cljs | 4 +- .../data/workspace/tokens/library_edit.cljs | 61 +- frontend/src/app/main/ui/forms.cljs | 1 - .../tokens/management/context_menu.cljs | 6 +- .../tokens/management/forms/color.cljs | 51 +- .../forms/controls/color_input.cljs | 1 - .../tokens/management/forms/font_family.cljs | 7 + .../management/forms/form_container.cljs | 3 +- .../tokens/management/forms/generic_form.cljs | 43 +- .../tokens/management/forms/shadow.cljs | 9 +- .../tokens/management/forms/typography.cljs | 9 +- .../tokens/management/forms/validators.cljs | 25 +- .../tokens/management/token_pill.cljs | 12 +- .../app/main/ui/workspace/tokens/sets.cljs | 5 +- .../ui/workspace/tokens/sets/helpers.cljs | 41 +- .../main/ui/workspace/tokens/sets/lists.cljs | 7 +- .../workspace/tokens/themes/create_modal.cljs | 58 +- frontend/src/app/plugins/tokens.cljs | 309 ++--- frontend/src/app/plugins/utils.cljs | 42 +- frontend/src/app/util/i18n.cljs | 5 + frontend/src/app/util/object.cljc | 21 +- .../frontend_tests/tokens/helpers/tokens.cljs | 4 +- .../tokens/logic/token_data_test.cljs | 3 +- plugins/README.md | 37 +- .../apps/poc-tokens-plugin/eslint.config.js | 51 + plugins/apps/poc-tokens-plugin/project.json | 79 ++ .../src/app/app.component.css | 127 ++ .../src/app/app.component.html | 144 +++ .../src/app/app.component.ts | 290 +++++ .../poc-tokens-plugin/src/app/app.config.ts | 11 + .../poc-tokens-plugin/src/app/app.routes.ts | 3 + .../apps/poc-tokens-plugin/src/assets/CORS | 1 + .../poc-tokens-plugin/src/assets/_headers | 2 + .../poc-tokens-plugin/src/assets/favicon.ico | Bin 0 -> 15406 bytes .../poc-tokens-plugin/src/assets/icon.png | Bin 0 -> 35196 bytes .../src/assets/manifest.json | 14 + plugins/apps/poc-tokens-plugin/src/index.html | 13 + plugins/apps/poc-tokens-plugin/src/main.ts | 7 + plugins/apps/poc-tokens-plugin/src/model.ts | 112 ++ plugins/apps/poc-tokens-plugin/src/plugin.ts | 262 ++++ plugins/apps/poc-tokens-plugin/src/styles.css | 23 + .../apps/poc-tokens-plugin/tsconfig.app.json | 10 + .../poc-tokens-plugin/tsconfig.editor.json | 7 + plugins/apps/poc-tokens-plugin/tsconfig.json | 33 + .../poc-tokens-plugin/tsconfig.plugin.json | 8 + plugins/libs/plugin-types/index.d.ts | 1080 ++++++++++++++++- plugins/package.json | 1 + 61 files changed, 3336 insertions(+), 736 deletions(-) create mode 100644 common/src/app/common/i18n.cljc create mode 100644 plugins/apps/poc-tokens-plugin/eslint.config.js create mode 100644 plugins/apps/poc-tokens-plugin/project.json create mode 100644 plugins/apps/poc-tokens-plugin/src/app/app.component.css create mode 100644 plugins/apps/poc-tokens-plugin/src/app/app.component.html create mode 100644 plugins/apps/poc-tokens-plugin/src/app/app.component.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/app/app.config.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/app/app.routes.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/assets/CORS create mode 100644 plugins/apps/poc-tokens-plugin/src/assets/_headers create mode 100644 plugins/apps/poc-tokens-plugin/src/assets/favicon.ico create mode 100644 plugins/apps/poc-tokens-plugin/src/assets/icon.png create mode 100644 plugins/apps/poc-tokens-plugin/src/assets/manifest.json create mode 100644 plugins/apps/poc-tokens-plugin/src/index.html create mode 100644 plugins/apps/poc-tokens-plugin/src/main.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/model.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/plugin.ts create mode 100644 plugins/apps/poc-tokens-plugin/src/styles.css create mode 100644 plugins/apps/poc-tokens-plugin/tsconfig.app.json create mode 100644 plugins/apps/poc-tokens-plugin/tsconfig.editor.json create mode 100644 plugins/apps/poc-tokens-plugin/tsconfig.json create mode 100644 plugins/apps/poc-tokens-plugin/tsconfig.plugin.json 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 0000000000000000000000000000000000000000..fc5e208af4ff02eaad5a27b0a74c269b8ea09c1b GIT binary patch literal 15406 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cf045fb5e6a44121b2b4eccf90c6e4499790abac GIT binary patch literal 35196 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 + + + + 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",