From 02e10a005a9d25406195b2e397d53f48180d7021 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Fri, 16 Oct 2026 17:58:51 +0200 Subject: [PATCH] :sparkles: Add token source to plugins (#12183) * :sparkles: Add token source to plugins * :sparkles: Changes after review * :sparkles: Move plugins code to library-edit --- common/src/app/common/files/tokens.cljc | 6 +- common/src/app/common/logic/tokens.cljc | 29 +- .../test/common_tests/logic/token_test.cljc | 55 ++- .../data/workspace/tokens/library_edit.cljs | 47 ++- .../src/app/main/ui/workspace/libraries.cljs | 18 +- frontend/src/app/plugins/library.cljs | 33 ++ frontend/src/app/plugins/tokens.cljs | 18 +- frontend/src/app/plugins/utils.cljs | 6 +- .../test/frontend_tests/helpers/state.cljs | 4 +- .../frontend_tests/plugins/tokens_test.cljs | 341 +++++++++++++++++- .../tokens/logic/token_data_test.cljs | 65 +++- mcp/packages/server/data/api_types.yml | 46 ++- mcp/types-generator/prepare_api_docs.py | 2 + mcp/types-generator/test_prepare_api_docs.py | 31 ++ plugins/CHANGELOG.md | 1 + .../src/generated/api-surface.json | 16 +- plugins/libs/plugin-types/index.d.ts | 44 ++- 17 files changed, 717 insertions(+), 45 deletions(-) create mode 100644 mcp/types-generator/test_prepare_api_docs.py diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e78adf2c47..e8a28e7ca2 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -523,7 +523,9 @@ (effective-tokens-source? file-data (:id file-data)))) (defn editable-tokens? - "Returns true if the file-data is its own tokens source." + "Returns true if the file-data is its own tokens source. + Call only with the current local file's data. This predicate does not check + which file is open; callers must reject connected libraries first." [file-data] (effective-tokens-source? file-data (:id file-data))) @@ -810,4 +812,4 @@ {:active-theme-ids active-theme-ids :active-set-ids active-set-ids}) {:active-theme-ids #{} - :active-set-ids #{}})) \ No newline at end of file + :active-set-ids #{}})) diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index b09145ec1c..53d2ae5396 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -15,15 +15,34 @@ ;; Tokens source +(defn- own-tokens-status + "The current tokens status of `file-data`, keeping only the themes and sets + that exist in its own tokens lib." + [file-data] + (let [tokens-status (cfo/get-tokens-status file-data) + tokens-lib (cfo/get-tokens-lib file-data)] + (if (and tokens-status tokens-lib) + (cfo/sync-tokens-status-with-lib tokens-status tokens-lib) + (ctos/make-tokens-status)))) + (defn generate-set-tokens-source "Create changes for setting the tokens source of a file to `library`, - copying the library's tokens status. If the library is nil, the tokens source - is removed and the tokens status is cleared." + copying the library's tokens status. If the library is the file itself, + the themes and sets that don't exist in its own tokens lib are deactivated. + If the library is nil, the tokens source is removed and the tokens status + is cleared." [changes library] (let [library-id (:id library) - library-tokens-status (if library - (-> library ctf/file-data cfo/get-tokens-status) - (ctos/make-tokens-status))] + file-data (pcb/get-library-data changes) + library-tokens-status (cond + (nil? library) + (ctos/make-tokens-status) + + (= library-id (:id file-data)) + (own-tokens-status file-data) + + :else + (-> library ctf/file-data cfo/get-tokens-status))] (-> changes (pcb/set-tokens-source library-id) (pcb/set-tokens-status library-tokens-status)))) diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 20ab2e59fb..799d895f44 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -177,7 +177,60 @@ (t/is (= (ctos/get-active-theme-ids file-tokens-status) (ctos/get-active-theme-ids undo-tokens-status))) (t/is (= (ctos/get-active-set-ids file-tokens-status) - (ctos/get-active-set-ids undo-tokens-status)))))) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file, themes and sets of the previous source are deactivated" + (let [set-id (uuid/next) + external-set-id (uuid/next) + external-theme-id (uuid/next) + library-id (uuid/next) + file (-> (tht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id + :name "one set"))) + :status-fn #(ctos/set-tokens-status % #{external-theme-id} + #{set-id external-set-id})) + (assoc-in [:data :tokens-source] library-id)) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + undo (thf/apply-undo-changes redo changes) + file-tokens-status (tht/get-tokens-status file) + redo-tokens-source (tht/get-tokens-source redo) + redo-tokens-status (tht/get-tokens-status redo) + undo-tokens-source (tht/get-tokens-source undo) + undo-tokens-status (tht/get-tokens-status undo)] + + ;; Redo: only the themes and sets of the file's own lib stay active + (t/is (nil? redo-tokens-source)) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{set-id} (ctos/get-active-set-ids redo-tokens-status))) + + ;; Undo: source and status restored to the original state + (t/is (= library-id undo-tokens-source)) + (t/is (= (ctos/get-active-theme-ids file-tokens-status) + (ctos/get-active-theme-ids undo-tokens-status))) + (t/is (= (ctos/get-active-set-ids file-tokens-status) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file and it has no own tokens lib, the tokens status is cleared" + (let [set-id (uuid/next) + file (-> (thf/sample-file :file1) + (update :data dissoc :tokens-lib) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/set-tokens-status (ctos/make-tokens-status) + #{} #{set-id}))) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + redo-tokens-status (tht/get-tokens-status redo)] + + (t/is (nil? (tht/get-tokens-source redo))) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{} (ctos/get-active-set-ids redo-tokens-status)))))) ;; Tokens lib 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 f8b276cdf3..6b22169719 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.tokens.library-edit (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -48,6 +49,28 @@ ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn editable-tokens-status? + "True when file-id is the current file. Its activation status is editable + even when its tokens come from an external source." + [state file-id] + (= file-id (:current-file-id state))) + +(defn editable-tokens? + "True when file-id is the current file and uses its own tokens source. + Use this predicate when the file may be a connected library." + [state file-id] + (and (editable-tokens-status? state file-id) + (cfo/editable-tokens? (dsh/lookup-file-data state file-id)))) + +(defn- check-editable-tokens! + "Throws when the tokens of the current file come from an external library, + so they can't be modified from this file." + [state] + (when-not (editable-tokens? state (:current-file-id state)) + (ex/raise :type :assertion + :code :tokens-not-editable + :hint "cannot modify the tokens of an external library"))) + ;; TODO HYMA: Copied over from workspace.cljs (defn update-shape [id attrs] @@ -268,6 +291,7 @@ (ptk/reify ::create-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and tokens-lib (ctob/get-theme tokens-lib (ctob/get-id token-theme))) @@ -286,6 +310,7 @@ (ptk/reify ::update-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and (not= id (ctob/get-id token-theme)) @@ -322,7 +347,7 @@ (ptk/reify ::toggle-token-theme-active ptk/WatchEvent (watch [it state _] - (let [data (dsh/lookup-tokens-source-data state) + (let [data (dsh/lookup-file-data state) tokens-status (dsh/lookup-tokens-status state) tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -338,6 +363,7 @@ (ptk/reify ::delete-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -352,6 +378,7 @@ (ptk/reify ::create-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -366,6 +393,7 @@ (ptk/reify ::update-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -377,7 +405,8 @@ [set-group-path set-group-fname] (ptk/reify ::rename-token-set-group ptk/WatchEvent - (watch [it _state _] + (watch [it state _] + (check-editable-tokens! state) (let [changes (-> (pcb/empty-changes it) (pcb/rename-token-set-group set-group-path set-group-fname))] (rx/of @@ -390,6 +419,7 @@ (ptk/reify ::duplicate-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) suffix (tr "workspace.tokens.duplicate-suffix")] @@ -458,6 +488,7 @@ (ptk/reify ::import-tokens-lib ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) status (cfo/make-tokens-status-from-lib lib) changes (-> (pcb/empty-changes it) @@ -486,6 +517,7 @@ (ptk/reify ::delete-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -498,6 +530,7 @@ (ptk/reify ::delete-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -528,6 +561,7 @@ (ptk/reify ::drop-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (when-let [changes (clo/generate-move-token-set-group (pcb/empty-changes it) (dsh/lookup-tokens-lib state) drop-opts)] (rx/of @@ -544,6 +578,7 @@ (ptk/reify ::drop-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (let [tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -559,6 +594,7 @@ (ptk/reify ::create-token-and-set ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) @@ -594,6 +630,7 @@ (ptk/reify ::create-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (if-let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state))] @@ -621,6 +658,7 @@ (ptk/reify ::bulk-create-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (lookup-token-set state set-id) data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] @@ -650,6 +688,7 @@ (ptk/reify ::update-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -678,6 +717,7 @@ (ptk/reify ::bulk-update-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -708,6 +748,7 @@ (ptk/reify ::delete-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) token-set (if set-id @@ -731,6 +772,7 @@ (ptk/reify ::bulk-delete-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] (pcb/set-token changes set-id token-id nil)) @@ -746,6 +788,7 @@ (ptk/reify ::duplicate-token ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (when-let [token-set (lookup-token-set state)] (when-let [tokens-lib (dsh/lookup-tokens-lib state)] (when-let [token (ctob/get-token tokens-lib diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 7ed01913be..cfb41dfb3d 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -489,9 +489,10 @@ (tr "common.publish")])] (for [{:keys [id name data connected-to connected-to-names] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css :section-list-item-legacy) :key (dm/str id) :data-testid "library-item"} @@ -508,7 +509,7 @@ [:span ")"]])])]] [:div {:class (stl/css :library-actions)} - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") @@ -865,9 +866,10 @@ :title-class (stl/css :connected-libraries-title)}] (if (seq linked-libraries) (for [{:keys [id name data connected-to nested? parent-name] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css-case :section-list-item true :section-list-item-nested nested?) :key (dm/str id) @@ -893,7 +895,7 @@ :data-library-id (dm/str id) :on-click set-as-tokens-source} (tr "workspace.libraries.set-as-tokens-source")]) - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index a5a70e1cfa..803cca9ffd 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] [app.common.files.variant :as cfv] [app.common.geom.point :as gpt] [app.common.schema :as sm] @@ -22,6 +23,7 @@ [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.variants :as dwv] [app.main.fonts :as fonts] [app.main.repo :as rp] @@ -1300,6 +1302,37 @@ (let [libraries (get @st/state :files)] (apply array (->> libraries keys (map (partial library-proxy plugin-id))))))} + :tokensSource + {:get + (fn [] + (let [file-id (:current-file-id @st/state) + file-data (ctf/file-data (u/locate-file file-id))] + (library-proxy plugin-id (cfo/get-effective-tokens-source file-data)))) + :set + (fn [value] + (let [file-id (:current-file-id @st/state) + library-id (when (library-proxy? value) (obj/get value "$id")) + library (some-> library-id u/locate-file) + library-data (some-> library ctf/file-data) + file-data (ctf/file-data (u/locate-file file-id))] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :tokensSource "Plugin doesn't have 'content:write' permission") + + (not (library-proxy? value)) + (u/not-valid plugin-id :tokensSource value) + + (nil? library) + (u/not-valid plugin-id :tokensSource "Library is not connected") + + (and (not= library-id file-id) + (not (cfo/tokens-provider? library-data))) + (u/not-valid plugin-id :tokensSource "Library must have its own tokens") + + (not (cfo/effective-tokens-source? file-data library-id)) + (st/emit! (-> (dwtl/set-tokens-source library-id) + (se/add-event plugin-id))))))} + :availableLibraries (fn [] (let [team-id (:current-team-id @st/state)] diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ae0f6bf171..f1895d7efc 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -11,7 +11,6 @@ [app.common.files.tokens :as cfo] [app.common.json :as json] [app.common.schema :as sm] - [app.common.types.file :as ctf] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] @@ -512,8 +511,11 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else - (st/emit! (dwtl/set-enabled-token-set id value))))} ;; This can be done even with tokens in an external library + (st/emit! (dwtl/set-enabled-token-set id value))))} :toggleActive (fn [] @@ -521,6 +523,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-set id)))) @@ -737,6 +742,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/set-token-theme-active id value))))} @@ -746,6 +754,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-theme-active id)))) @@ -839,8 +850,7 @@ :enumerable false :get (fn [] - (let [file (u/locate-file file-id)] - (cfo/editable-tokens? (ctf/file-data file))))} + (dwtl/editable-tokens? @st/state file-id))} :themes {:this true diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index fc6a921799..22f57d45fd 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -18,6 +18,7 @@ [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.util.object :as obj] [cuerdas.core :as str])) @@ -288,9 +289,8 @@ (defn check-editable-tokens [file-id] - (let [file (locate-file file-id)] - (when-not (cfo/editable-tokens? (ctf/file-data file)) - (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library")))))) + (when-not (dwtl/editable-tokens? @st/state file-id) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library"))))) (defn throw-validation-errors? [plugin-id] diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index 894caa2783..82e4e3d32c 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -25,7 +25,7 @@ ;; profile renderer preference instead of the global feature flag alone. :profile {:props {:renderer :wasm}}}) -(defn- on-error +(defn- report-error [cause] (js/console.error "STORE ERROR" (.-stack cause)) (t/do-report {:type :error :message "Store error" :actual cause}) @@ -34,7 +34,7 @@ (defn setup-store ([file] (setup-store file nil)) - ([file {:keys [renderer libraries] :as _opts}] + ([file {:keys [renderer libraries on-error] :or {on-error report-error} :as _opts}] (let [state (-> initial-state (assoc :current-file-id (:id file) :current-page-id (cthf/current-page-id file) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 56a3a2529d..f731c98910 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -18,17 +18,21 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.undo :as dwu] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.library :as library] [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] [app.util.object :as obj] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.async :as tha] + [clojure.datafy :refer [datafy]] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] + [frontend-tests.tokens.helpers.state :as tohs] [potok.v2.core :as ptk])) (t/use-fixtures :each @@ -71,7 +75,7 @@ #js {:strokeColor "#000000" :strokeWidth 1}]) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-strokes rect) index) property value) - (await (tha/settle)) + (await (async/settle)) (t/is (= (apply dissoc tokens removed) (get-in @store path))) (t/is (= value (obj/get (aget (.-strokes rect) index) property)))))))) @@ -97,15 +101,344 @@ (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (let [^js gradient (obj/get (aget (.-strokes rect) 0) "strokeColorGradient")] (set! (.-startX gradient) 0.5) - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= 0.5 (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "startX"))) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-stops gradient) 0) "color" "#ff0000") - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= "#ff0000" (obj/get (aget (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "stops") 0) "color")))))) +(defn- token-source-file + [file-label] + (let [set-id (uuid/next)] + (ctht/sample-file-with-tokens + :file-id file-label + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id :name "Core")) + (ctob/add-theme (ctob/make-token-theme :name "Light" + :group "Mode" + :sets #{"Core"})) + (ctob/add-token set-id (ctob/make-token :name "color.primary" + :type :color + :value "#FF0000"))) + :status-fn #(ctos/set-tokens-status % #{} #{set-id})))) + +(defn- with-active-theme + "The tokens status of `file` with its first visible theme activated." + [file] + (let [tokens-lib (ctht/get-tokens-lib file) + theme-id (:id (first (remove ctob/hidden-theme? (ctob/get-themes tokens-lib))))] + (cfo/activate-theme (ctht/get-tokens-status file) tokens-lib theme-id))) + +(defn- tokens-unchanged? + "True when the token attributes of `file` in the store are the original ones." + [store file] + (let [attrs [:tokens-lib :tokens-status :tokens-source]] + (= (select-keys (:data file) attrs) + (select-keys (get-in @store [:files (:id file) :data]) attrs)))) + +(defn- record-not-valid + "A `u/not-valid` stub that collects each call into `errors`." + [errors] + (mock/stub (fn [plugin-id code value] (swap! errors conj [plugin-id code value])))) + +(t/deftest ^:async library-tokens-source-switches-to-connected-library + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "library token source")) + (t/is (= (str (:id source)) (.. context -tokensSource -id))) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (= (ctht/get-tokens-lib source) (u/locate-tokens-lib (:id file)))) + (t/is (false? (.. context -local -tokens -isEditableTokens))) + (t/is (= (ctht/get-tokens-lib file) + (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source)))))) + +(t/deftest ^:async library-tokens-source-restores-local-tokens + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) (.-local context)) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "local token source")) + (let [^js catalog (.. context -local -tokens)] + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (t/is (true? (.-isEditableTokens catalog))) + (t/is (= (ctht/get-tokens-lib file) (u/locate-tokens-lib (:id file)))) + (t/is (= #{} (ctos/get-active-set-ids (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (not-any? #(.-active ^js %) (.-themes catalog)))))))) + +(t/deftest ^:async library-tokens-source-supports-undo-and-redo + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (st/emit! (tohs/watch-undo-stack)) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "token source undo entry")) + (st/emit! dwu/undo) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "undo token source")) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (st/emit! dwu/redo) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "redo token source")) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))))))) + +(t/deftest ^:async library-tokens-source-keeps-status-when-source-is-unchanged + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) source-proxy) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async library-tokens-source-rejects-invalid-libraries + (let [file (token-source-file :local) + empty-lib (ctht/sample-file-with-tokens :file-id :empty) + indirect (-> (token-source-file :indirect) + (assoc-in [:data :tokens-source] (uuid/next))) + store (ths/setup-store file {:libraries [empty-lib indirect]}) + plugin-id (str uuid/zero) + ^js context (library/library-subcontext plugin-id) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (doseq [[label value message] + [["not a library" "invalid" "invalid"] + ["not connected" (library/library-proxy plugin-id (uuid/next)) "Library is not connected"] + ["without own tokens" (library/library-proxy plugin-id (:id empty-lib)) "Library must have its own tokens"] + ["with another tokens source" (library/library-proxy plugin-id (:id indirect)) "Library must have its own tokens"]]] + (t/testing label + (reset! errors []) + (set! (.-tokensSource context) value) + (t/is (= [[plugin-id :tokensSource message]] @errors)))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async external-catalogs-reject-tokens-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero)] + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (doseq [[catalog-label ^js catalog] + [["local catalog" (.. (library/library-subcontext plugin-id) -local -tokens)] + ["source catalog" (.. (library/library-subcontext plugin-id) -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-isEditableTokens catalog))) + (doseq [[label mutate] + [["create set" #(.addSet catalog #js {:name "New"})] + ["rename set" #(set! (.-name token-set) "Renamed")] + ["duplicate set" #(.duplicate token-set)] + ["remove set" #(.remove token-set)] + ["create token" #(.addToken token-set #js {:name "color.new" :type "color" :value "#00FF00"})] + ["rename token" #(set! (.-name token) "color.renamed")] + ["change token value" #(set! (.-value token) "#00FF00")] + ["change token description" #(set! (.-description token) "New description")] + ["duplicate token" #(.duplicate token)] + ["remove token" #(.remove token)] + ["create theme" #(.addTheme catalog #js {:name "Dark" :group "Mode"})] + ["rename theme" #(set! (.-name theme) "Dark")] + ["change theme group" #(set! (.-group theme) "Other")] + ["add set to theme" #(.addSet theme token-set)] + ["remove set from theme" #(.removeSet theme token-set)] + ["duplicate theme" #(.duplicate theme)] + ["remove theme" #(.remove theme)]]] + (t/testing label + (t/is (thrown-with-msg? js/Error #"Cannot modify tokens in an external library" + (mutate))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other))))))))))) + +(t/deftest ^:async external-catalogs-reject-status-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero) + message "Cannot change activation through a connected library; use penpot.library.local.tokens" + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (let [^js context (library/library-subcontext plugin-id)] + (t/is (true? (.-active ^js (aget (.. context -local -tokens -themes) 0)))) + (doseq [[catalog-label ^js catalog] + [["source catalog" (.. context -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/testing "reports the status of its own file" + (t/is (true? (.-active token-set))) + (t/is (false? (.-active theme)))) + (reset! errors []) + (set! (.-active token-set) false) + (.toggleActive token-set) + (set! (.-active theme) true) + (.toggleActive theme) + (await (async/settle)) + (t/is (= [[plugin-id :active message] + [plugin-id :toggleActive message] + [plugin-id :active message] + [plugin-id :toggleActive message]] + @errors)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other)))))))))) + +(t/deftest ^:async external-token-source-allows-set-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js token-set (aget (.-sets catalog) 0)] + (t/is (true? (.-active token-set))) + (set! (.-active token-set) false) + (await (async/wait-for #(false? (.-active token-set)) "deactivate set")) + (.toggleActive token-set) + (await (async/wait-for #(true? (.-active token-set)) "toggle set")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-theme-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-active theme))) + (set! (.-active theme) true) + (await (async/wait-for #(true? (.-active theme)) "activate theme")) + (.toggleActive theme) + (await (async/wait-for #(false? (.-active theme)) "toggle theme")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-theme-toggle-supports-undo + (let [source (token-source-file :source) + status (with-active-theme source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] status)) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (st/emit! (tohs/watch-undo-stack)) + (.toggleActive theme) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "theme toggle undo entry")) + (t/is (false? (.-active theme))) + (st/emit! dwu/undo) + (await (async/wait-for #(= (datafy status) + (datafy (get-in @store [:files (:id file) :data :tokens-status]))) + "undo external theme toggle")) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-applying-tokens-to-shapes + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source)) + (ctho/add-rect :rect1) + (ctho/add-rect :rect2) + (ctho/add-rect :rect3)) + store (ths/setup-store file {:libraries [source]}) + page-id (cthf/current-page-id file)] + (swap! store assoc-in [:workspace-local :selected] #{(cthi/id :rect3)}) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js context (api/create-context (str uuid/zero)) + ^js page (.-currentPage context) + ^js catalog (.. context -library -local -tokens) + ^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js rect1 (.getShapeById page (str (cthi/id :rect1))) + ^js rect2 (.getShapeById page (str (cthi/id :rect2)))] + (.applyToken rect1 token #js ["fill"]) + (.applyToShapes token #js [rect2] #js ["fill"]) + (.applyToSelected token #js ["fill"]) + (doseq [label [:rect1 :rect2 :rect3]] + (let [shape-path [:files (:id file) :data :pages-index page-id :objects (cthi/id label)]] + (await (async/wait-for #(= "color.primary" (get-in @store (conj shape-path :applied-tokens :fill))) + (str "apply external token to " label))) + (await (async/wait-for #(= "#ff0000" (get-in @store (conj shape-path :fills 0 :fill-color))) + (str "resolve external token on " label))))) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async library-tokens-source-requires-content-write-permission + (let [file (token-source-file :local) + source (token-source-file :source) + plugin-id "read-only-plugin" + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext plugin-id) + source-proxy (library/library-proxy plugin-id (:id source)) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (set! (.-tokensSource context) source-proxy) + (t/is (= [[plugin-id :tokensSource "Plugin doesn't have 'content:write' permission"]] @errors)) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or 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 ee2d94a31f..fe1a145ce8 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -15,10 +15,12 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.undo :as dwu] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] [frontend-tests.tokens.helpers.state :as tohs] - [frontend-tests.tokens.helpers.tokens :as toht])) + [frontend-tests.tokens.helpers.tokens :as toht] + [potok.v2.core :as ptk])) (t/use-fixtures :each {:before thp/reset-idmap!}) @@ -895,3 +897,64 @@ (t/testing "Tokens lib has been imported into file without existing lib" (t/is (some? tokens-lib')) (t/is (some? (ctob/get-set-by-name tokens-lib' "Imported Set")))))))))) + +(defn- setup-tokens-source-library + [] + (ctho/sample-file-with-tokens + :file-id :library + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :source-set) + :name "Source")) + (ctob/add-token (cthi/id :source-set) + (ctob/make-token :id (cthi/new-id! :source-token) + :name "color.source" + :type :color + :value "#FF0000")) + (ctob/add-theme (ctob/make-token-theme :id (cthi/new-id! :source-theme) + :name "Light" + :group "Mode" + :sets #{"Source"}))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :source-set)}))) + +(t/deftest ^:async external-tokens-source-rejects-tokens-lib-changes + (let [source (setup-tokens-source-library) + file (-> (setup-file-with-token-lib) + (assoc-in [:data :tokens-source] (:id source))) + errors (atom []) + store (ths/setup-store file {:libraries [source] + :on-error #(swap! errors conj %)}) + tokens-lib (ctho/get-tokens-lib source) + set-id (cthi/id :source-set) + token-id (cthi/id :source-token) + theme-id (cthi/id :source-theme)] + (doseq [[label event] + [["create theme" (dwtl/create-token-theme (ctob/make-token-theme :name "Dark" :group "Mode"))] + ["update theme" (dwtl/update-token-theme theme-id (ctob/rename (ctob/get-theme tokens-lib theme-id) "Dark"))] + ["delete theme" (dwtl/delete-token-theme theme-id)] + ["create set" (dwtl/create-token-set (ctob/make-token-set :name "New"))] + ["rename set" (dwtl/rename-token-set (ctob/get-set tokens-lib set-id) "Renamed")] + ["rename set group" (dwtl/rename-token-set-group ["Source"] "Renamed")] + ["duplicate set" (dwtl/duplicate-token-set set-id)] + ["delete set" (dwtl/delete-token-set set-id)] + ["delete set group" (dwtl/delete-token-set-group ["Source"])] + ["drop set group" (dwtl/drop-token-set-group {:from-index 0 :to-index 0 :position :top})] + ["drop set" (dwtl/drop-token-set {:from-index 0 :to-index 0 :position :top})] + ["import tokens lib" (dwtl/import-tokens-lib (ctob/make-tokens-lib))] + ["create token" (dwtl/create-token set-id (ctob/make-token :name "color.new" + :type :color + :value "#00FF00"))] + ["bulk create tokens" (dwtl/bulk-create-tokens set-id [token-id] :color {:depth 0} "brand")] + ["update token" (dwtl/update-token set-id token-id {:value "#00FF00"})] + ["bulk update tokens" (dwtl/bulk-update-tokens set-id [token-id] :color "color" "brand")] + ["delete token" (dwtl/delete-token set-id token-id)] + ["bulk delete tokens" (dwtl/bulk-delete-tokens set-id [token-id])] + ["duplicate token" (dwtl/duplicate-token token-id)]]] + (t/testing label + (reset! errors []) + (ptk/emit! store event) + (await (async/wait-for #(seq @errors) label)) + (t/is (= [{:type :assertion :code :tokens-not-editable}] + (map #(select-keys (ex-data %) [:type :code]) @errors))))) + (await (async/settle)) + (t/is (= file (get-in @store [:files (:id file)]))) + (t/is (= source (get-in @store [:files (:id source)]))))) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index e0ebcde698..cbfb243052 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -22426,10 +22426,12 @@ TokenCatalog: readonly isEditableTokens: boolean ``` - Returns true if the tokens can be edited (it can't - if the tokens of the file are in an external library). - Note that even in this case, Themes and Sets status still - can be changed and tokens can be applied to shapes. + Returns true if the tokens in this catalog can be edited. Only the + catalog of the current file (`penpot.library.local.tokens`) can be + edited, and only when the file is its own tokens source. + When the tokens come from an external library, the local catalog + can still change the active themes and sets, and its tokens can + still be applied to shapes. themes: |- ``` readonly themes: TokenTheme[] @@ -22564,7 +22566,8 @@ TokenSet: active: boolean ``` - Indicates if the set is currently active. + Indicates if the set is active in the file that owns this catalog. + Only sets from `penpot.library.local.tokens` can be changed. tokens: |- ``` readonly tokens: Token[] @@ -22584,6 +22587,7 @@ TokenSet: ``` Toggles the active status of this set. + Only sets from `penpot.library.local.tokens` can be changed. Returns void getTokenById: |- @@ -22707,7 +22711,8 @@ TokenTheme: active: boolean ``` - Indicates if the theme is currently active. + Indicates if the theme is active in the file that owns this catalog. + Only themes from `penpot.library.local.tokens` can be changed. activeSets: |- ``` readonly activeSets: TokenSet[] @@ -22721,6 +22726,7 @@ TokenTheme: ``` Toggles the active status of this theme. + Only themes from `penpot.library.local.tokens` can be changed. Returns void addSet: |- @@ -23297,6 +23303,7 @@ LibraryContext: type LibraryContext = { local: Library; connected: Library[]; + tokensSource: Library; availableLibraries(): Promise; connectLibrary(libraryId: string): Promise; } @@ -23327,6 +23334,33 @@ LibraryContext: ``` const connectedLibraries = libraryContext.connected; ``` + tokensSource: |- + ``` + tokensSource: Library + ``` + + The library that supplies tokens to the current file. + Defaults to the local library. + + Assign a connected library that has its own tokens to use it as the + source. This copies its active themes and sets to the current file. + Assign `local` to use the file's own tokens again. Themes and sets + that don't exist in the local tokens are deactivated. + Setting the source requires `content:write` permission and supports undo. + Assigning the current source again keeps the active themes and sets. + + The catalog of an external `tokensSource` is read-only and reports the + library's own active themes and sets. To change which ones are active + in the current file, use `penpot.library.local.tokens`, which lists the + tokens of the source. + + Example + ``` + const source = await penpot.library.connectLibrary('library-id'); + penpot.library.tokensSource = source; + // Restore local tokens: + penpot.library.tokensSource = penpot.library.local; + ``` Methods: availableLibraries: |- ``` diff --git a/mcp/types-generator/prepare_api_docs.py b/mcp/types-generator/prepare_api_docs.py index a01ec4e6ef..143f110c44 100644 --- a/mcp/types-generator/prepare_api_docs.py +++ b/mcp/types-generator/prepare_api_docs.py @@ -63,6 +63,8 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter): if node.name == "pre": for button in soup.find_all("button"): button.decompose() + for br in soup.find_all("br"): + br.replace_with("\n") return f"\n```\n{soup.get_text().strip()}\n```\n\n" # convert tsd-signature elements to code blocks, converting
to newlines diff --git a/mcp/types-generator/test_prepare_api_docs.py b/mcp/types-generator/test_prepare_api_docs.py new file mode 100644 index 0000000000..bb2fc631eb --- /dev/null +++ b/mcp/types-generator/test_prepare_api_docs.py @@ -0,0 +1,31 @@ +import unittest + +from prepare_api_docs import PenpotAPIContentMarkdownConverter + + +class PenpotAPIContentMarkdownConverterTest(unittest.TestCase): + def test_preserves_line_breaks_in_code_examples(self): + html = ( + '
'
+            'const source = await penpot.library.connectLibrary("library-id");
' + 'penpot.library.tokensSource = source;
' + '// Restore local tokens:
' + 'penpot.library.tokensSource = penpot.library.local;' + '
' + ) + + result = PenpotAPIContentMarkdownConverter().convert(html).strip() + + self.assertEqual( + result, + '```\n' + 'const source = await penpot.library.connectLibrary("library-id");\n' + 'penpot.library.tokensSource = source;\n' + '// Restore local tokens:\n' + 'penpot.library.tokensSource = penpot.library.local;\n' + '```', + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index af3cc43b0b..fbd46deec9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -4,6 +4,7 @@ - **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission. - **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method. +- **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 5034ba9730..4fb9d6b689 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -301,7 +301,8 @@ "availableLibraries", "connectLibrary", "connected", - "local" + "local", + "tokensSource" ], "LibraryElement": ["id", "libraryId", "name", "path"], "LibrarySummary": [ @@ -544,6 +545,7 @@ "addTheme", "getSetById", "getThemeById", + "isEditableTokens", "sets", "themes" ], @@ -5318,6 +5320,12 @@ "type": "Library", "array": true }, + "tokensSource": { + "decl": "LibraryContext", + "kind": "getset", + "type": "Library", + "array": false + }, "availableLibraries": { "decl": "LibraryContext", "kind": "method", @@ -9394,6 +9402,12 @@ } }, "TokenCatalog": { + "isEditableTokens": { + "decl": "TokenCatalog", + "kind": "get", + "type": null, + "array": false + }, "themes": { "decl": "TokenCatalog", "kind": "get", diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 66d73cc4af..aa7008dc13 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2892,6 +2892,32 @@ export type LibraryContext = { */ readonly connected: Library[]; + /** + * The library that supplies tokens to the current file. + * Defaults to the local library. + * + * Assign a connected library that has its own tokens to use it as the + * source. This copies its active themes and sets to the current file. + * Assign `local` to use the file's own tokens again. Themes and sets + * that don't exist in the local tokens are deactivated. + * Setting the source requires `content:write` permission and supports undo. + * Assigning the current source again keeps the active themes and sets. + * + * The catalog of an external `tokensSource` is read-only and reports the + * library's own active themes and sets. To change which ones are active + * in the current file, use `penpot.library.local.tokens`, which lists the + * tokens of the source. + * + * @example + * ```js + * const source = await penpot.library.connectLibrary('library-id'); + * penpot.library.tokensSource = source; + * // Restore local tokens: + * penpot.library.tokensSource = penpot.library.local; + * ``` + */ + tokensSource: Library; + /** * Retrieves a summary of available libraries that can be connected to. * @return Returns a promise that resolves to an array of `LibrarySummary` objects representing available libraries. @@ -5293,10 +5319,12 @@ export type Token = */ export interface TokenCatalog { /** - * Returns true if the tokens can be edited (it can't - * if the tokens of the file are in an external library). - * Note that even in this case, Themes and Sets status still - * can be changed and tokens can be applied to shapes. + * Returns true if the tokens in this catalog can be edited. Only the + * catalog of the current file (`penpot.library.local.tokens`) can be + * edited, and only when the file is its own tokens source. + * When the tokens come from an external library, the local catalog + * can still change the active themes and sets, and its tokens can + * still be applied to shapes. */ readonly isEditableTokens: boolean; @@ -5370,7 +5398,8 @@ export interface TokenSet { name: string; /** - * Indicates if the set is currently active. + * Indicates if the set is active in the file that owns this catalog. + * Only sets from `penpot.library.local.tokens` can be changed. */ active: boolean; @@ -5386,6 +5415,7 @@ export interface TokenSet { /** * Toggles the active status of this set. + * Only sets from `penpot.library.local.tokens` can be changed. */ toggleActive(): void; @@ -5470,12 +5500,14 @@ export interface TokenTheme { name: string; /** - * Indicates if the theme is currently active. + * Indicates if the theme is active in the file that owns this catalog. + * Only themes from `penpot.library.local.tokens` can be changed. */ active: boolean; /** * Toggles the active status of this theme. + * Only themes from `penpot.library.local.tokens` can be changed. */ toggleActive(): void;