From d06b45ec90ee3d99d4e7999eca5288724485202c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 29 Apr 2026 16:44:59 +0000 Subject: [PATCH] :bug: Fix Plugin API token application for JS array of strings Two coupled defects made shape.applyToken(), token.applyToShapes() and token.applyToSelected() silently no-op when invoked from JavaScript with an array of strings (e.g. token.applyToShapes([rect], ["fill"])): 1. token-attr-plugin->token-attr only consulted its alias map when the input was already a keyword; string inputs fell through unchanged, causing downstream token-attr? to return false. 2. The inner schemas used plain [:set ...] which lacks the :decode/json transformer for JS array -> Clojure set coercion. Switching to Penpot's custom [::sm/set ...] lets the standard JSON decoder pipeline handle the conversion automatically. This is a backport of commit 1eac3e2be5f973359ad2ec9bac4e80a9d5a9e022 which fixes GitHub #9162. Signed-off-by: Andrey Antukh --- CHANGES.md | 1 + frontend/src/app/plugins/shape.cljs | 2 +- frontend/src/app/plugins/tokens.cljs | 17 +++- .../frontend_tests/plugins/tokens_test.cljs | 82 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/tokens_test.cljs diff --git a/CHANGES.md b/CHANGES.md index 067a4438d8..ef1b89e575 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,7 @@ - Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296) - Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) +- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162) ## 2.14.4 diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 722822df79..fed9a9f0c1 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1313,7 +1313,7 @@ {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 13888f0481..a6f1d3a850 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -49,8 +49,19 @@ (get map:token-attr->token-attr-plugin k k)) (defn token-attr-plugin->token-attr + "Resolve a plugin-side token attribute reference to its canonical + internal keyword. + + Accepts either a Clojure keyword (the canonical form, e.g. `:r1`, + `:fill`) or a string (the natural shape that arrives from a JS plugin + call such as `shape.applyToken(token, [\"fill\"])`). Converts strings + to keywords first, then maps verbose plugin-side aliases (e.g. + `:border-radius-top-left`) to their internal short form (e.g. `:r1`). + Inputs that are already in canonical form (`:r1`, `:fill`, `\"fill\"`, + …) pass through unchanged." [k] - (get map:token-attr-plugin->token-attr k k)) + (let [k (cond-> k (string? k) keyword)] + (get map:token-attr-plugin->token-attr k k))) (defn applied-tokens-plugin->applied-tokens [value] @@ -186,13 +197,13 @@ {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs new file mode 100644 index 0000000000..3c0d1cda1a --- /dev/null +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -0,0 +1,82 @@ +;; 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 frontend-tests.plugins.tokens-test + (:require + [app.plugins.tokens :as ptok] + [cljs.test :as t :include-macros true])) + +;; Regression coverage for issue #9162. +;; +;; Plugin code calling `shape.applyToken(token, ["fill"])` or +;; `token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS +;; array of strings. Penpot's plugin proxies expect a Clojure set of +;; keywords. Two coupled defects made these calls silently no-op (or, with +;; `throwValidationErrors` enabled, throw a "check error"): +;; +;; 1. `token-attr-plugin->token-attr` only consulted its alias map when +;; the input was already a keyword — string inputs like "fill" or +;; "border-radius-top-left" fell through to the identity branch +;; unchanged, so the downstream `cto/token-attr?` predicate (which +;; checks against a set of keywords) returned false. +;; 2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas used +;; plain `[:set ...]`, which does not have a `:decode/json` +;; transformer for the JS array → Clojure set coercion. Penpot's +;; custom `[::sm/set ...]` does. Switching to the registered set type +;; lets the standard JSON decoder pipeline turn the JS argument into +;; a set of strings, after which the `[:and ::sm/keyword [:fn +;; token-attr?]]` element schema coerces each string to a keyword and +;; validates it. +;; +;; These helper-level tests pin the string-friendly conversion contract; +;; the schema-level fix is covered by the existing plugin integration +;; suite that exercises `applyToken` end-to-end. + +(t/deftest token-attr-plugin->token-attr-passes-canonical-form-through + ;; Both already-canonical short names and unaliased names pass through + ;; unchanged. + (t/is (= :fill (ptok/token-attr-plugin->token-attr :fill))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr :stroke-color))) + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :r1))) + (t/is (= :p2 (ptok/token-attr-plugin->token-attr :p2)))) + +(t/deftest token-attr-plugin->token-attr-resolves-verbose-plugin-aliases + ;; Plugin-side verbose names (e.g. `:border-radius-top-left`) map to + ;; their canonical short internal form (`:r1`) so plugin authors can + ;; spell the corner explicitly without the engine having to know both. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :border-radius-top-left))) + (t/is (= :r2 (ptok/token-attr-plugin->token-attr :border-radius-top-right))) + (t/is (= :r3 (ptok/token-attr-plugin->token-attr :border-radius-bottom-right))) + (t/is (= :r4 (ptok/token-attr-plugin->token-attr :border-radius-bottom-left))) + (t/is (= :p1 (ptok/token-attr-plugin->token-attr :padding-top-left))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom-right)))) + +(t/deftest token-attr-plugin->token-attr-coerces-string-input + ;; This is the actual regression — JS plugin calls supply strings. + (t/is (= :fill (ptok/token-attr-plugin->token-attr "fill"))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr "stroke-color"))) + ;; Verbose plugin aliases work via the string path too. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr "border-radius-top-left"))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr "margin-bottom-right")))) + +(t/deftest token-attr?-accepts-keyword-input + (t/is (true? (boolean (ptok/token-attr? :fill)))) + (t/is (true? (boolean (ptok/token-attr? :stroke-color)))) + (t/is (true? (boolean (ptok/token-attr? :r1)))) + (t/is (true? (boolean (ptok/token-attr? :p2))))) + +(t/deftest token-attr?-accepts-string-input + ;; Same JS-array-of-strings reproducer as the issue, exercised at the + ;; predicate layer the plugin schemas call into. + (t/is (true? (boolean (ptok/token-attr? "fill")))) + (t/is (true? (boolean (ptok/token-attr? "stroke-color")))) + (t/is (true? (boolean (ptok/token-attr? "r1")))) + (t/is (true? (boolean (ptok/token-attr? "m3"))))) + +(t/deftest token-attr?-rejects-unknown-input + (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) + (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) + (t/is (false? (boolean (ptok/token-attr? nil))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b7b53f8fbb..8260e62a45 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -19,6 +19,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.tokens-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -61,6 +62,7 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.tokens-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test