🐛 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 <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-04-29 16:44:59 +00:00
parent 1e09e00634
commit d06b45ec90
5 changed files with 100 additions and 4 deletions

View File

@ -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

View File

@ -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))]

View File

@ -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)))}))

View File

@ -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)))))

View File

@ -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