mirror of
https://github.com/penpot/penpot.git
synced 2026-01-29 08:41:55 -05:00
Compare commits
9 Commits
niwinz-mcp
...
tokens-api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f860b248 | ||
|
|
cbfa2b2f0a | ||
|
|
c01bfe2ef9 | ||
|
|
b43187291e | ||
|
|
55a4ebe668 | ||
|
|
dac11733d6 | ||
|
|
341cfc474b | ||
|
|
68cf2ecc57 | ||
|
|
3e4f70f37b |
@@ -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"}
|
||||
|
||||
@@ -8,8 +8,224 @@
|
||||
(: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
|
||||
(-> cto/schema:token-name
|
||||
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
|
||||
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
#(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]
|
||||
(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]
|
||||
(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]
|
||||
(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 +296,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))
|
||||
|
||||
|
||||
15
common/src/app/common/i18n.cljc
Normal file
15
common/src/app/common/i18n.cljc
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,30 @@
|
||||
[& 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)]
|
||||
(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 +295,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 +882,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
|
||||
|
||||
@@ -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,6 +59,26 @@
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -66,7 +86,6 @@
|
||||
(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,93 +128,95 @@
|
||||
(def token-types
|
||||
(into #{} (keys token-type->dtcg-token-type)))
|
||||
|
||||
(def token-name-ref
|
||||
[:re {:title "TokenNameRef" :gen/gen sg/text}
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SCHEMA: Token
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(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}
|
||||
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
|
||||
|
||||
(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-attribute> -> <token-name>.
|
||||
;; 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
|
||||
@@ -204,6 +225,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
|
||||
@@ -213,91 +257,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"}])
|
||||
@@ -318,11 +380,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
|
||||
@@ -364,21 +443,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
|
||||
@@ -387,20 +458,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
|
||||
@@ -416,12 +489,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)))
|
||||
|
||||
@@ -432,42 +507,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}
|
||||
@@ -497,7 +536,48 @@
|
||||
:stroke-color #{:color}})
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TYPOGRAPHY
|
||||
;; HELPERS for tokens application
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; TODO it seems that this function is redundant, maybe?
|
||||
;; (defn- toggle-or-apply-token
|
||||
;; "Remove any shape attributes from token if they exists.
|
||||
;; Othewise apply token attributes."
|
||||
;; [shape token]
|
||||
;; (let [[only-in-shape only-in-token _matching] (data/diff (:applied-tokens shape) token)]
|
||||
;; (merge {} only-in-shape only-in-token)))
|
||||
|
||||
(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 apply-token-to-shape
|
||||
;; [{:keys [shape token attributes] :as _props}]
|
||||
;; (let [map-to-apply (generate-attr-map token attributes)
|
||||
;; applied-tokens (toggle-or-apply-token shape map-to-apply)]
|
||||
;; (update shape :applied-tokens #(merge % applied-tokens))))
|
||||
|
||||
(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
|
||||
@@ -560,32 +640,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))
|
||||
|
||||
@@ -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,13 @@
|
||||
[o]
|
||||
(instance? TokenSetLegacy o))
|
||||
|
||||
(declare make-token-set)
|
||||
(declare normalized-set-name?)
|
||||
|
||||
(def schema:token-set-attrs
|
||||
[:map {:title "TokenSet"}
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:name [:and :string [:fn #(normalized-set-name? %)]]]
|
||||
[:description {:optional true} :string]
|
||||
[:modified-at {:optional true} ::ct/inst]
|
||||
[:tokens {:optional true
|
||||
@@ -342,8 +339,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 +399,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 +1378,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 +1446,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:
|
||||
|
||||
@@ -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"}}}}))))
|
||||
|
||||
@@ -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)
|
||||
{}))
|
||||
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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"}}}}))))
|
||||
|
||||
@@ -181,8 +181,7 @@ FROM base AS setup-utils
|
||||
|
||||
ENV CLJKONDO_VERSION=2026.01.19 \
|
||||
BABASHKA_VERSION=1.12.208 \
|
||||
CLJFMT_VERSION=0.15.6 \
|
||||
PIXI_VERSION=0.63.2
|
||||
CLJFMT_VERSION=0.15.6
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
@@ -225,26 +224,6 @@ RUN set -ex; \
|
||||
tar -xf /tmp/babashka.tar.gz; \
|
||||
rm -rf /tmp/babashka.tar.gz;
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
cd /tmp; \
|
||||
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
|
||||
cd /opt/utils/bin; \
|
||||
tar -xf /tmp/pixi.tar.gz; \
|
||||
rm -rf /tmp/pixi.tar.gz;
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
FROM ubuntu:24.04
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -ex; \
|
||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
||||
mkdir -p /etc/resolvconf/resolv.conf.d; \
|
||||
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
|
||||
apt-get -qq update; \
|
||||
apt-get -qqy --no-install-recommends install \
|
||||
curl \
|
||||
tzdata \
|
||||
locales \
|
||||
ca-certificates \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
|
||||
mkdir -p /opt/node; \
|
||||
cd /opt/node; \
|
||||
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
|
||||
chown -R root /opt/node; \
|
||||
rm -rf /tmp/nodejs.tar.gz; \
|
||||
corepack enable; \
|
||||
mkdir -p /opt/penpot; \
|
||||
chown -R penpot:penpot /opt/penpot;
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-mcp/"
|
||||
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/
|
||||
|
||||
WORKDIR /opt/penpot/mcp
|
||||
USER penpot:penpot
|
||||
|
||||
RUN ./setup
|
||||
|
||||
CMD ["node", "index.js", "--multi-user"]
|
||||
@@ -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]
|
||||
@@ -83,7 +83,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))]
|
||||
|
||||
@@ -109,7 +109,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))
|
||||
@@ -127,7 +127,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))
|
||||
@@ -151,7 +151,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))
|
||||
@@ -249,7 +249,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)
|
||||
|
||||
@@ -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]
|
||||
@@ -644,8 +644,8 @@
|
||||
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])
|
||||
tokenized-attributes (cft/attributes-map attributes token)
|
||||
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
|
||||
tokenized-attributes (cfo/attributes-map attributes token)
|
||||
type (:type token)]
|
||||
(rx/concat
|
||||
(rx/of
|
||||
@@ -704,7 +704,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of
|
||||
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token %))]
|
||||
(let [remove-token #(when % (cfo/remove-attributes-for-token attributes token %))]
|
||||
(dwsh/update-shapes
|
||||
shape-ids
|
||||
(fn [shape]
|
||||
@@ -732,7 +732,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)]
|
||||
|
||||
@@ -755,6 +755,43 @@
|
||||
:shape-ids shape-ids
|
||||
:on-update-shape on-update-shape}))))))))
|
||||
|
||||
(defn toggle-border-radius-token
|
||||
[{:keys [token attrs shape-ids expand-with-children]}]
|
||||
(ptk/reify ::on-toggle-border-radius-token
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shapes (into [] (keep (d/getf objects)) shape-ids)
|
||||
|
||||
shapes
|
||||
(if expand-with-children
|
||||
(into []
|
||||
(mapcat (fn [shape]
|
||||
(if (= (:type shape) :group)
|
||||
(keep objects (:shapes shape))
|
||||
[shape])))
|
||||
shapes)
|
||||
shapes)
|
||||
|
||||
{:keys [attributes all-attributes]}
|
||||
(get token-properties (:type token))
|
||||
|
||||
unapply-tokens?
|
||||
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
|
||||
|
||||
shape-ids (map :id shapes)]
|
||||
|
||||
(if unapply-tokens?
|
||||
(rx/of
|
||||
(unapply-token {:attributes (or attrs all-attributes attributes)
|
||||
:token token
|
||||
:shape-ids shape-ids}))
|
||||
(rx/of
|
||||
(apply-token {:attributes attrs
|
||||
:token token
|
||||
:shape-ids shape-ids
|
||||
:on-update-shape update-shape-radius-for-corners})))))))
|
||||
|
||||
(defn apply-token-on-selected
|
||||
[color-operations token]
|
||||
(ptk/reify ::apply-token-on-selected
|
||||
|
||||
@@ -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.main.data.tinycolor :as tinycolor]))
|
||||
|
||||
(defn color-bullet-color [token-color-value]
|
||||
@@ -17,5 +17,5 @@
|
||||
(tinycolor/->hex-string tc))))
|
||||
|
||||
(defn resolved-token-bullet-color [{:keys [resolved-value] :as token}]
|
||||
(when (and resolved-value (cft/color-token? token))
|
||||
(when (and resolved-value (cfo/color-token? token))
|
||||
(color-bullet-color resolved-value)))
|
||||
@@ -149,27 +149,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]
|
||||
@@ -181,26 +184,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
|
||||
@@ -433,11 +416,18 @@
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
token-set (if set-id
|
||||
(lookup-token-set state set-id)
|
||||
(lookup-token-set state))
|
||||
token (-> (get-tokens-lib state)
|
||||
(ctob/get-token (ctob/get-id token-set) token-id))
|
||||
token-type (:type token)
|
||||
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token set-id token-id nil))]
|
||||
(rx/of (dch/commit-changes changes))))))
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "delete-token" :type token-type}))))))
|
||||
|
||||
(defn bulk-delete-tokens
|
||||
[set-id token-ids]
|
||||
@@ -445,9 +435,15 @@
|
||||
(dm/assert! (every? uuid? token-ids))
|
||||
(ptk/reify ::bulk-delete-tokens
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(apply rx/of
|
||||
(map #(delete-token set-id %) token-ids)))))
|
||||
(watch [it state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
changes (reduce (fn [changes token-id]
|
||||
(pcb/set-token changes set-id token-id nil))
|
||||
(-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data))
|
||||
token-ids)]
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "delete-token-node"}))))))
|
||||
|
||||
(defn duplicate-token
|
||||
[token-id]
|
||||
@@ -461,7 +457,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
|
||||
|
||||
@@ -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))}))
|
||||
|
||||
|
||||
@@ -6,22 +6,23 @@
|
||||
|
||||
(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.common.types.token :as cto]
|
||||
#_[app.common.types.tokens-lib :as ctob]
|
||||
[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]
|
||||
#_[app.util.i18n :refer [tr]]
|
||||
#_[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
#_(defn- token-value-error-fn
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
|
||||
(defn- make-schema
|
||||
#_(defn- make-schema
|
||||
[tokens-tree _]
|
||||
(sm/schema
|
||||
[:and
|
||||
@@ -29,9 +30,9 @@
|
||||
[: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")))
|
||||
(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 [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
@@ -47,7 +48,9 @@
|
||||
(nil? (cto/token-value-self-reference? name value))))]]))
|
||||
|
||||
(mf/defc form*
|
||||
[props]
|
||||
(let [props (mf/spread-props props {:make-schema make-schema
|
||||
[{:keys [token-type] :as props}]
|
||||
(let [props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type)
|
||||
(sm/dissoc-key :id)
|
||||
(sm/assoc-key :color-result {:optional true} :any))
|
||||
:input-component token.controls/color-input*})]
|
||||
[:> generic/form* props]))
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
color-resolved
|
||||
(get-in @form [:data :color-result] "")
|
||||
|
||||
|
||||
valid-color (or (tinycolor/valid-color value)
|
||||
(tinycolor/valid-color color-resolved))
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
(: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.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.constants :refer [max-input-length]]
|
||||
[app.main.data.helpers :as dh]
|
||||
[app.main.data.modal :as modal]
|
||||
@@ -36,13 +37,12 @@
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
#_(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,7 +59,7 @@
|
||||
|
||||
value))
|
||||
|
||||
(defn- default-make-schema
|
||||
#_(defn- default-make-schema
|
||||
[tokens-tree _]
|
||||
(sm/schema
|
||||
[:and
|
||||
@@ -67,9 +67,9 @@
|
||||
[: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")))
|
||||
(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 [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
:error/fn #(tr "workspace.tokens.self-reference")}
|
||||
(fn [{:keys [name value]}]
|
||||
(when (and name value)
|
||||
(nil? (cto/token-value-self-reference? name value))))]]))
|
||||
(not (cto/token-value-self-reference? name value))))]]))
|
||||
|
||||
(mf/defc form*
|
||||
[{:keys [token
|
||||
@@ -97,12 +97,13 @@
|
||||
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))) ;; TODO this does not work because the schema is no longer a :map but a :multi
|
||||
input-component (or input-component token.controls/input*)
|
||||
validate-token (or validator default-validate-token)
|
||||
validate-token (or validator default-validate-token)
|
||||
|
||||
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
|
||||
active-tab (deref active-tab*)
|
||||
active-tab* (mf/use-state #(if (cfo/is-reference? token) :reference :composite))
|
||||
active-tab (deref active-tab*)
|
||||
|
||||
token
|
||||
(mf/with-memo [token]
|
||||
@@ -134,7 +135,8 @@
|
||||
initial
|
||||
(mf/with-memo [token]
|
||||
(or initial
|
||||
{:name (:name token "")
|
||||
{:type token-type
|
||||
:name (:name token "")
|
||||
:value (:value token "")
|
||||
:description (:description token "")}))
|
||||
|
||||
|
||||
@@ -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, adding :optional when needed
|
||||
(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
|
||||
|
||||
@@ -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
|
||||
@@ -217,10 +217,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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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.main.data.workspace.tokens.application :as dwta]
|
||||
@@ -156,9 +156,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?]}]
|
||||
@@ -178,7 +178,7 @@
|
||||
(let [{:keys [name value errors type]} 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) $
|
||||
@@ -191,7 +191,7 @@
|
||||
|
||||
applied?
|
||||
(if has-selected?
|
||||
(cft/shapes-token-applied? token selected-shapes attributes)
|
||||
(cfo/shapes-token-applied? token selected-shapes attributes)
|
||||
false)
|
||||
|
||||
half-applied?
|
||||
@@ -219,7 +219,7 @@
|
||||
no-valid-value)
|
||||
|
||||
color
|
||||
(when (cft/color-token? token)
|
||||
(when (cfo/color-token? token)
|
||||
(let [theme-token (get active-theme-tokens name)]
|
||||
(or (dwtc/resolved-token-bullet-color theme-token)
|
||||
(dwtc/resolved-token-bullet-color token))))
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
.modal-title {
|
||||
@include t.use-typography("headline-medium");
|
||||
color: var(--modal-title-foreground-color);
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
@@ -62,7 +62,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?
|
||||
@@ -79,6 +80,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}]))
|
||||
|
||||
@@ -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)
|
||||
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})))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,33 +7,39 @@
|
||||
(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]
|
||||
[clojure.datafy :refer [datafy]]))
|
||||
[beicon.v2.core :as rx]
|
||||
[clojure.datafy :refer [datafy]]
|
||||
[app.common.json :as json]))
|
||||
|
||||
;; === 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
|
||||
[plugin-id file-id set-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)}
|
||||
:$set-id {:enumerable false :get (constantly set-id)}
|
||||
@@ -48,18 +54,12 @@
|
||||
(fn [_]
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(ctob/get-name token)))
|
||||
:schema (cfo/make-token-name-schema
|
||||
(-> (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 +73,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 +110,24 @@
|
||||
(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
|
||||
[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)}
|
||||
@@ -122,13 +143,15 @@
|
||||
(ctob/get-name set)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(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)))))}
|
||||
(let [set (u/locate-token-set file-id id)
|
||||
name (u/coerce-1 value
|
||||
(cfo/make-token-set-name-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
id)
|
||||
:setTokenSet
|
||||
"Invalid token set name")]
|
||||
(when name
|
||||
(st/emit! (dwtl/rename-token-set set name)))))}
|
||||
|
||||
:active
|
||||
{:this true
|
||||
@@ -140,8 +163,13 @@
|
||||
(ctob/token-set-active? tokens-lib (ctob/get-name set))))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
|
||||
(let [value (u/coerce-1 value
|
||||
(sm/schema [:boolean])
|
||||
:setActiveSet
|
||||
value)]
|
||||
(when (some? value)
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))))}
|
||||
|
||||
:toggleActive
|
||||
(fn [_]
|
||||
@@ -153,8 +181,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 +192,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)
|
||||
@@ -193,35 +219,37 @@
|
||||
(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 []
|
||||
@@ -252,12 +280,15 @@
|
||||
(:group theme)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(cond
|
||||
(not (string? value))
|
||||
(u/display-not-valid :group value)
|
||||
|
||||
:else
|
||||
(let [theme (u/locate-token-theme file-id id)
|
||||
group (u/coerce-1 value
|
||||
(cfo/make-token-theme-group-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
(:name theme)
|
||||
(:id theme))
|
||||
:group
|
||||
"Invalid token theme group")]
|
||||
(when group
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :group value))))))}
|
||||
|
||||
:name
|
||||
@@ -269,16 +300,14 @@
|
||||
: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
|
||||
name (u/coerce-1 value
|
||||
(cfo/make-token-theme-name-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
(:id theme)
|
||||
(:group theme))
|
||||
:name
|
||||
"Invalid token theme name")]
|
||||
(when name
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :name value))))))}
|
||||
|
||||
:active
|
||||
@@ -333,8 +362,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)
|
||||
themes (->> (ctob/get-themes tokens-lib)
|
||||
(remove #(= (:id %) uuid/zero)))]
|
||||
(apply array (map #(token-theme-proxy plugin-id file-id (ctob/get-id %)) themes))))}
|
||||
@@ -344,36 +372,36 @@
|
||||
: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)
|
||||
sets (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)))))
|
||||
(fn [attrs]
|
||||
(let [schema (-> (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
|
||||
attrs (u/coerce attrs schema :addTheme "invalid theme attrs")]
|
||||
(when 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)
|
||||
|
||||
: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 [attrs]
|
||||
(obj/update! attrs "name" ctob/normalize-set-name) ;; TODO: seems a quite weird way of doing this
|
||||
(let [schema (-> (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
|
||||
attrs (u/coerce attrs schema :addSet "invalid set attrs")]
|
||||
(when attrs
|
||||
(let [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]
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
(: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]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]))
|
||||
[app.util.object :as obj]
|
||||
[cljs.stacktrace :as stk]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn locate-file
|
||||
[id]
|
||||
@@ -218,7 +222,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 +231,44 @@
|
||||
(.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 coerce-1
|
||||
"Checks a single javascript value against schema. If schema validation fails,
|
||||
displays a not-valid message with the code and hint provided and returns nil."
|
||||
[value schema code hint]
|
||||
(let [errors (sm/validation-errors value schema)]
|
||||
(if (d/not-empty? errors)
|
||||
(display-not-valid code (str hint " " (str/join ", " errors)))
|
||||
value)))
|
||||
|
||||
(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)))))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -253,7 +258,11 @@
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
val-sym `(~coercer-sym ~val-sym)]
|
||||
[])]
|
||||
[])
|
||||
|
||||
~fn-sym ~(if wrap
|
||||
`(~wrap-sym ~fn-sym)
|
||||
fn-sym)]
|
||||
|
||||
~(if this?
|
||||
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
40
manage.sh
40
manage.sh
@@ -215,23 +215,6 @@ function build-frontend-bundle {
|
||||
echo ">> bundle frontend end";
|
||||
}
|
||||
|
||||
function build-mcp-bundle {
|
||||
echo ">> bundle mcp start";
|
||||
|
||||
mkdir -p ./bundles
|
||||
local version=$(print-current-version);
|
||||
local bundle_dir="./bundles/mcp";
|
||||
|
||||
build "mcp";
|
||||
|
||||
rm -rf $bundle_dir;
|
||||
mv ./mcp/dist $bundle_dir;
|
||||
echo $version > $bundle_dir/version.txt;
|
||||
put-license-file $bundle_dir;
|
||||
echo ">> bundle mcp end";
|
||||
}
|
||||
|
||||
|
||||
function build-backend-bundle {
|
||||
echo ">> bundle backend start";
|
||||
|
||||
@@ -326,16 +309,6 @@ function build-exporter-docker-image {
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-mcp-docker-image {
|
||||
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
|
||||
pushd ./docker/images;
|
||||
docker build \
|
||||
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
|
||||
--build-arg BUNDLE_PATH="./bundle-mcp/" \
|
||||
-f Dockerfile.mcp .;
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-storybook-docker-image {
|
||||
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
|
||||
pushd ./docker/images;
|
||||
@@ -373,7 +346,6 @@ function usage {
|
||||
echo "- build-frontend-docker-image Build frontend docker images."
|
||||
echo "- build-backend-docker-image Build backend docker images."
|
||||
echo "- build-exporter-docker-image Build exporter docker images."
|
||||
echo "- build-mcp-docker-image Build exporter docker images."
|
||||
echo "- build-storybook-docker-image Build storybook docker images."
|
||||
echo ""
|
||||
echo "- version Show penpot's version."
|
||||
@@ -425,7 +397,6 @@ case $1 in
|
||||
## production builds
|
||||
build-bundle)
|
||||
build-frontend-bundle;
|
||||
build-mcp-bundle;
|
||||
build-backend-bundle;
|
||||
build-exporter-bundle;
|
||||
build-storybook-bundle;
|
||||
@@ -435,10 +406,6 @@ case $1 in
|
||||
build-frontend-bundle;
|
||||
;;
|
||||
|
||||
build-mcp-bundle)
|
||||
build-mcp-bundle;
|
||||
;;
|
||||
|
||||
build-backend-bundle)
|
||||
build-backend-bundle;
|
||||
;;
|
||||
@@ -464,7 +431,6 @@ case $1 in
|
||||
build-frontend-docker-image
|
||||
build-backend-docker-image
|
||||
build-exporter-docker-image
|
||||
build-mcp-docker-image
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
@@ -479,11 +445,7 @@ case $1 in
|
||||
build-exporter-docker-image)
|
||||
build-exporter-docker-image
|
||||
;;
|
||||
|
||||
build-mcp-docker-image)
|
||||
build-mcp-docker-image
|
||||
;;
|
||||
|
||||
|
||||
build-storybook-docker-image)
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
11
mcp/.gitignore
vendored
11
mcp/.gitignore
vendored
@@ -1,11 +0,0 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
||||
*.bak
|
||||
*.orig
|
||||
temp
|
||||
*.tsbuildinfo
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
@@ -1,7 +0,0 @@
|
||||
*.md
|
||||
*.json
|
||||
python-scripts/
|
||||
.serena/
|
||||
|
||||
# auto-generated files
|
||||
mcp-server/data/api_types.yml
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.yml",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
1
mcp/.serena/.gitignore
vendored
1
mcp/.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,25 +0,0 @@
|
||||
# Code Style and Conventions
|
||||
|
||||
## General Principles
|
||||
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
|
||||
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
|
||||
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
|
||||
|
||||
## TypeScript Configuration
|
||||
- **Strict Mode**: All strict TypeScript options enabled
|
||||
- **Target**: ES2022
|
||||
- **Module System**: CommonJS
|
||||
- **Declaration Files**: Generated with source maps
|
||||
|
||||
## Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
|
||||
- **Interfaces**: PascalCase (e.g., `Tool`)
|
||||
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
|
||||
- **Constants**: camelCase for readonly properties (e.g., `definition`)
|
||||
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
|
||||
|
||||
## Documentation Style
|
||||
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
|
||||
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
|
||||
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Penpot MCP Project Overview - Updated
|
||||
|
||||
## Purpose
|
||||
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
|
||||
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
|
||||
- **Package Manager**: pnpm
|
||||
- **WebSocket**: ws library for real-time communication
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
penpot-mcp/
|
||||
├── common/ # Shared type definitions
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Exports for shared types
|
||||
│ │ └── types.ts # PluginTaskResult, request/response interfaces
|
||||
│ └── package.json # @penpot-mcp/common package
|
||||
├── mcp-server/ # Main MCP server implementation
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Main server entry point
|
||||
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
|
||||
│ │ ├── PluginTask.ts # Now supports result promises
|
||||
│ │ ├── tasks/ # PluginTask implementations
|
||||
│ │ └── tools/ # Tool implementations
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
├── penpot-plugin/ # Penpot plugin with response capability
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
|
||||
│ │ └── plugin.ts # Now sends task responses back to server
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
└── prepare-api-docs # Python project for the generation of API docs
|
||||
```
|
||||
|
||||
## Key Tasks
|
||||
|
||||
### Adding a new Tool
|
||||
|
||||
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
|
||||
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
|
||||
2. Register the tool in `PenpotMcpServer`.
|
||||
|
||||
Look at `PrintTextTool` as an example.
|
||||
|
||||
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
|
||||
|
||||
### Adding a new PluginTask
|
||||
|
||||
1. Implement the input data interface for the task in `common/src/types.ts`.
|
||||
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
|
||||
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
|
||||
* In the success case, call `task.sendSuccess`.
|
||||
* In the failure case, just throw an exception, which will be handled centrally!
|
||||
* Look at `PrintTextTaskHandler` as an example.
|
||||
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
|
||||
|
||||
|
||||
## Key Components
|
||||
|
||||
### Enhanced WebSocket Protocol
|
||||
- **Request Format**: `{id: string, task: string, params: any}`
|
||||
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
|
||||
- **Request/Response Correlation**: Using unique UUIDs for task tracking
|
||||
- **Timeout Handling**: 30-second timeout with automatic cleanup
|
||||
- **Type Safety**: Shared definitions via @penpot-mcp/common package
|
||||
|
||||
### Core Classes
|
||||
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
|
||||
- **PluginTask**: Now creates result promises that resolve when plugin responds
|
||||
- **Tool implementations**: Now properly await task completion and report results
|
||||
- **Plugin handlers**: Send structured responses back to server
|
||||
|
||||
### New Features
|
||||
1. **Bidirectional Communication**: Plugin now responds with success/failure status
|
||||
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
|
||||
3. **Error Reporting**: Failed tasks properly report error messages to tools
|
||||
4. **Shared Type Safety**: Common package ensures consistency across projects
|
||||
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
|
||||
6. **Request Correlation**: Unique IDs match requests to responses
|
||||
|
||||
## Task Flow
|
||||
|
||||
```
|
||||
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
|
||||
↑ ↓
|
||||
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
|
||||
```
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Suggested Commands
|
||||
|
||||
## Development Commands
|
||||
```bash
|
||||
# Navigate to MCP server directory
|
||||
cd penpot/mcp/server
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the TypeScript project
|
||||
pnpm run build
|
||||
|
||||
# Start the server (production)
|
||||
pnpm run start
|
||||
|
||||
# Start the server in development mode
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Testing and Development
|
||||
```bash
|
||||
# Run TypeScript compiler in watch mode
|
||||
pnpx tsc --watch
|
||||
|
||||
# Check TypeScript compilation without emitting files
|
||||
pnpx tsc --noEmit
|
||||
```
|
||||
|
||||
## Windows-Specific Commands
|
||||
```cmd
|
||||
# Directory navigation
|
||||
cd penpot/mcp/server
|
||||
dir # List directory contents
|
||||
type package.json # Display file contents
|
||||
|
||||
# Git operations
|
||||
git status
|
||||
git add .
|
||||
git commit -m "message"
|
||||
git push
|
||||
|
||||
# File operations
|
||||
copy src\file.ts backup\file.ts # Copy files
|
||||
del dist\* # Delete files
|
||||
mkdir new-directory # Create directory
|
||||
rmdir /s directory # Remove directory recursively
|
||||
```
|
||||
|
||||
## Project Structure Navigation
|
||||
```bash
|
||||
# Key directories
|
||||
cd penpot/mcp/server/src # Source code
|
||||
cd penpot/mcp/server/src/tools # Tool implementations
|
||||
cd penpot/mcp/server/src/interfaces # Type definitions
|
||||
cd penpot/mcp/server/dist # Compiled output
|
||||
```
|
||||
|
||||
## Common Utilities
|
||||
```cmd
|
||||
# Search for text in files
|
||||
findstr /s /i "HelloWorld" *.ts
|
||||
|
||||
# Find files by name
|
||||
dir /s /b *Tool.ts
|
||||
|
||||
# Process management
|
||||
tasklist | findstr node # Find Node.js processes
|
||||
taskkill /f /im node.exe # Kill Node.js processes
|
||||
```
|
||||
@@ -1,56 +0,0 @@
|
||||
# Task Completion Guidelines
|
||||
|
||||
## After Making Code Changes
|
||||
|
||||
### 1. Build and Test
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm run build:full # or npm run build for faster bundling only
|
||||
```
|
||||
|
||||
### 2. Verify TypeScript Compilation
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
### 3. Test the Server
|
||||
```bash
|
||||
# Start in development mode to test changes
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Code Quality Checks
|
||||
- Ensure all code follows the established conventions
|
||||
- Verify JSDoc comments are complete and accurate
|
||||
- Check that error handling is appropriate
|
||||
- Use clean imports WITHOUT file extensions (esbuild handles resolution)
|
||||
- Validate that tool interfaces are properly implemented
|
||||
|
||||
### 5. Integration Testing
|
||||
- Test tool registration in the main server
|
||||
- Verify MCP protocol compliance
|
||||
- Ensure tool definitions match implementation
|
||||
|
||||
## Before Committing Changes
|
||||
1. **Build Successfully**: `npm run build:full` completes without errors
|
||||
2. **No TypeScript Errors**: `npx tsc --noEmit` passes
|
||||
3. **Documentation Updated**: JSDoc comments reflect changes
|
||||
4. **Tool Registry Updated**: New tools added to `registerTools()` method
|
||||
5. **Interface Compliance**: All tools implement the `Tool` interface correctly
|
||||
|
||||
## File Organization
|
||||
- Place new tools in `src/tools/` directory
|
||||
- Update main server registration in `src/index.ts`
|
||||
- Follow existing naming conventions
|
||||
|
||||
## Common Patterns
|
||||
- All tools must implement the `Tool` interface
|
||||
- Use readonly properties for tool definitions
|
||||
- Include comprehensive error handling
|
||||
- Follow the established documentation style
|
||||
- Import WITHOUT file extensions (esbuild resolves them automatically)
|
||||
|
||||
## Build System
|
||||
- Uses esbuild for fast bundling and TypeScript for declarations
|
||||
- Import statements should omit file extensions entirely
|
||||
- IDE refactoring is safe - no extension-related build failures
|
||||
@@ -1,130 +0,0 @@
|
||||
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: |
|
||||
IMPORTANT: You use an idiomatic, object-oriented style.
|
||||
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
|
||||
rather than mere functions (i.e. use the strategy pattern, for example).
|
||||
|
||||
Comments:
|
||||
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
|
||||
clearly defines *what* it is. Any details then follow in subsequent sentences.
|
||||
|
||||
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
|
||||
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
|
||||
required for sentences).
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "penpot-mcp"
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: utf-8
|
||||
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
239
mcp/README.md
239
mcp/README.md
@@ -1,239 +0,0 @@
|
||||

|
||||
|
||||
# Penpot's Official MCP Server
|
||||
|
||||
Penpot integrates a LLM layer built on the Model Context Protocol (MCP) via Penpot's Plugin API to interact with a Penpot design file. Penpot's MCP server enables LLMs to perfom data queries, transformation and creation operations.
|
||||
|
||||
Penpot's MCP Server is unlike any other you've seen. You get design-to- design, code-to-design and design-code supercharged workflows.
|
||||
|
||||
|
||||
[](https://www.youtube.com/playlist?list=PLgcCPfOv5v57SKMuw1NmS0-lkAXevpn10)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which support the retrieval
|
||||
of design data as well as the modification and creation of design elements.
|
||||
The MCP server communicates with Penpot via the dedicated **Penpot MCP Plugin**,
|
||||
which connects to the MCP server via WebSocket.
|
||||
This enables the LLM to carry out tasks in the context of a design file by
|
||||
executing code that leverages the Penpot Plugin API.
|
||||
The LLM is free to write and execute arbitrary code snippets
|
||||
within the Penpot Plugin environment to accomplish its tasks.
|
||||
|
||||

|
||||
|
||||
This repository thus contains not only the MCP server implementation itself
|
||||
but also the supporting Penpot MCP Plugin
|
||||
(see section [Repository Structure](#repository-structure) below).
|
||||
|
||||
## Demonstration
|
||||
|
||||
[](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
To use the Penpot MCP server, you must
|
||||
* run the MCP server and connect your AI client to it,
|
||||
* run the web server providing the Penpot MCP plugin, and
|
||||
* open the Penpot MCP plugin in Penpot and connect it to the MCP server.
|
||||
|
||||
Follow the steps below to enable the integration.
|
||||
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The project requires [Node.js](https://nodejs.org/) (tested with v22).
|
||||
Following the installation of Node.js, the tools `npm` and `npx` should be
|
||||
available in your terminal.
|
||||
|
||||
### 1. Build & Launch the MCP Server and the Plugin Server
|
||||
|
||||
If it's your first execution, install the required dependencies:
|
||||
```shell
|
||||
npm install
|
||||
```
|
||||
|
||||
Then build all components and start the two servers:
|
||||
```shell
|
||||
npm run bootstrap
|
||||
```
|
||||
|
||||
This bootstrap command will:
|
||||
* install dependencies for all components (`npm run install:all`)
|
||||
* build all components (`npm run build:all`)
|
||||
* start all components (`npm run start:all`)
|
||||
|
||||
|
||||
### 2. Load the Plugin in Penpot and Establish the Connection
|
||||
|
||||
> [!NOTE]
|
||||
> **Browser Connectivity Restrictions**
|
||||
>
|
||||
> Starting with Chromium version 142, the private network access (PNA) restrictions have been hardened,
|
||||
> and when connecting to `localhost` from a web application served from a different origin
|
||||
> (such as https://design.penpot.app), the connection must explicitly be allowed.
|
||||
>
|
||||
> Most Chromium-based browsers (e.g. Chrome, Vivaldi) will display a popup requesting permission
|
||||
> to access the local network. Be sure to approve the request to allow the connection.
|
||||
>
|
||||
> Some browsers take additional security measures, and you may need to disable them.
|
||||
> For example, in Brave, disable the "Shield" for the Penpot website to allow local network access.
|
||||
>
|
||||
> If your browser refuses to connect to the locally served plugin, check its configuration or
|
||||
> try a different browser (e.g. Firefox) that does not enforce these restrictions.
|
||||
|
||||
1. Open Penpot in your browser
|
||||
2. Navigate to a design file
|
||||
3. Open the Plugins menu
|
||||
4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default)
|
||||
5. Open the plugin UI
|
||||
6. In the plugin UI, click "Connect to MCP server".
|
||||
The connection status should change from "Not connected" to "Connected to MCP server".
|
||||
(Check the browser's developer console for WebSocket connection logs.
|
||||
Check the MCP server terminal for WebSocket connection messages.)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not close the plugin's UI while using the MCP server, as this will close the connection.
|
||||
|
||||
### 3. Connect an MCP Client
|
||||
|
||||
By default, the server runs on port 4401 and provides:
|
||||
|
||||
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
|
||||
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
|
||||
|
||||
These endpoints can be used directly by MCP clients that support them.
|
||||
Simply configure the client to connect the MCP server by providing the respective URL.
|
||||
|
||||
When using a client that only supports stdio transport,
|
||||
a proxy like `mcp-remote` is required.
|
||||
|
||||
#### Using a Proxy for stdio Transport
|
||||
|
||||
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
|
||||
allowing clients that support only stdio to connect to the MCP server indirectly.
|
||||
|
||||
1. Install `mcp-remote` globally if you haven't already:
|
||||
|
||||
npm install -g mcp-remote
|
||||
|
||||
2. Use `mcp-remote` to provide the launch command for your MCP client:
|
||||
|
||||
npx -y mcp-remote http://localhost:4401/sse --allow-http
|
||||
|
||||
#### Example: Claude Desktop
|
||||
|
||||
For Windows and macOS, there is the official [Claude Desktop app](https://claude.ai/download), which you can use as an MCP client.
|
||||
For Linux, there is an [unofficial community version](https://github.com/aaddrick/claude-desktop-debian).
|
||||
|
||||
Since Claude Desktop natively supports only stdio transport, you will need to use a proxy like `mcp-remote`.
|
||||
Install it as described above.
|
||||
|
||||
To add the server to Claude Desktop's configuration, locate the configuration file (or find it via Menu / File / Settings / Developer):
|
||||
|
||||
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
||||
|
||||
Add a `penpot` entry under `mcpServers` with the following content:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Be sure to fully quit the app for the changes to take effect; closing the window is *not* sufficient.
|
||||
> To fully terminate the app, choose Menu / File / Quit.
|
||||
|
||||
After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom
|
||||
of the prompt input area.
|
||||
|
||||
#### Example: Claude Code
|
||||
|
||||
To add the Penpot MCP server to a Claude Code project, issue the command
|
||||
|
||||
claude mcp add penpot -t http http://localhost:4401/mcp
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository is a monorepo containing four main components:
|
||||
|
||||
1. **Common Types** (`common/`):
|
||||
- Shared TypeScript definitions for request/response protocol
|
||||
- Ensures type safety across server and plugin components
|
||||
|
||||
2. **Penpot MCP Server** (`mcp-server/`):
|
||||
- Provides MCP tools to LLMs for Penpot interaction
|
||||
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
|
||||
- Implements request/response correlation with unique task IDs
|
||||
- Handles task timeouts and proper error reporting
|
||||
|
||||
3. **Penpot MCP Plugin** (`penpot-plugin/`):
|
||||
- Connects to the MCP server via WebSocket
|
||||
- Executes tasks in Penpot using the Plugin API
|
||||
- Sends structured responses back to the server#
|
||||
|
||||
4. **Helper Scripts** (`python-scripts/`):
|
||||
- Python scripts that prepare data for the MCP server (development use)
|
||||
|
||||
The core components are written in TypeScript, rendering interactions with the
|
||||
Penpot Plugin API both natural and type-safe.
|
||||
|
||||
## Configuration
|
||||
|
||||
The Penpot MCP server can be configured using environment variables. All configuration
|
||||
options use the `PENPOT_MCP_` prefix for consistency.
|
||||
|
||||
### Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------------------|----------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
|
||||
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
|
||||
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------|------------------------------------------------------|----------|
|
||||
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
|
||||
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
|
||||
|
||||
### Plugin Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
|
||||
|
||||
## Beyond Local Execution
|
||||
|
||||
The above instructions describe how to run the MCP server and plugin server locally.
|
||||
We are working on enabling remote deployments of the MCP server, particularly
|
||||
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
|
||||
be able to connect to the same MCP server instance.
|
||||
|
||||
To run the server remotely (even for a single user),
|
||||
you may set the following environment variables to configure the two servers
|
||||
(MCP server & plugin server) appropriately:
|
||||
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
|
||||
in remote mode, with local file system access disabled.
|
||||
* `PENPOT_MCP_SERVER_LISTEN_ADDRESS` and `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`:
|
||||
Set these according to your requirements for remote connectivity.
|
||||
To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks).
|
||||
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
|
||||
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
|
||||
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "mcp-common",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared type definitions and interfaces for Penpot MCP",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./types";
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Result of a plugin task execution.
|
||||
*
|
||||
* Contains the outcome status of a task and any additional result data.
|
||||
*/
|
||||
export interface PluginTaskResult<T> {
|
||||
/**
|
||||
* Optional result data from the task execution.
|
||||
*/
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request message sent from server to plugin.
|
||||
*
|
||||
* Contains a unique identifier, task name, and parameters for execution.
|
||||
*/
|
||||
export interface PluginTaskRequest {
|
||||
/**
|
||||
* Unique identifier for request/response correlation.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The name of the task to execute.
|
||||
*/
|
||||
task: string;
|
||||
|
||||
/**
|
||||
* The parameters for task execution.
|
||||
*/
|
||||
params: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response message sent from plugin back to server.
|
||||
*
|
||||
* Contains the original request ID and the execution result.
|
||||
*/
|
||||
export interface PluginTaskResponse<T> {
|
||||
/**
|
||||
* Unique identifier matching the original request.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Whether the task completed successfully.
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Optional error message if the task failed.
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* The result of the task execution.
|
||||
*/
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the executeCode task.
|
||||
*/
|
||||
export interface ExecuteCodeTaskParams {
|
||||
/**
|
||||
* The JavaScript code to be executed.
|
||||
*/
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data for the executeCode task.
|
||||
*/
|
||||
export interface ExecuteCodeTaskResultData<T> {
|
||||
/**
|
||||
* The result of the executed code, if any.
|
||||
*/
|
||||
result: T;
|
||||
|
||||
/**
|
||||
* Captured console output during code execution.
|
||||
*/
|
||||
log: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Multi-User Mode
|
||||
|
||||
> [!WARNING]
|
||||
> Multi-user mode is under development and not yet fully integrated.
|
||||
> This information is provided for testing purposes only.
|
||||
|
||||
The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users
|
||||
to connect to the same MCP server instance simultaneously.
|
||||
This supports remote deployments of the MCP server, without requiring each user
|
||||
to run their own server instance.
|
||||
|
||||
## Limitations
|
||||
|
||||
Multi-user mode has the limitation that tools which read from or write to
|
||||
the local file system are not supported, as the server cannot access
|
||||
the client's file system. This affects the import and export tools.
|
||||
|
||||
## Running Components in Multi-User Mode
|
||||
|
||||
To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing),
|
||||
you can use the following command:
|
||||
|
||||
```shell
|
||||
npm run bootstrap:multi-user
|
||||
```
|
||||
|
||||
This will:
|
||||
* launch the MCP server in multi-user mode (adding the `--multi-user` flag),
|
||||
* build and launch the Penpot MCP plugin server in multi-user mode.
|
||||
|
||||
See the package.json scripts for both `mcp-server` and `penpot-plugin` for details.
|
||||
|
||||
In multi-user mode, users are required to be authenticated via a token.
|
||||
|
||||
* This token is provided in the URL used to connect to the MCP server,
|
||||
e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`.
|
||||
* The same token must be provided when connecting the Penpot MCP plugin
|
||||
to the MCP server.
|
||||
In the future, the token will, most likely be generated by Penpot and
|
||||
provided to the plugin automatically.
|
||||
:warning: For now, it is hard-coded in the plugin's source code for testing purposes.
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "mcp-meta",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"install:all": "pnpm -r install",
|
||||
"build:all": "pnpm -r run build",
|
||||
"build:all:multi-user": "pnpm -r run build:multi-user",
|
||||
"start:all": "pnpm -r --parallel run start",
|
||||
"start:all:multi-user": "pnpm -r --parallel run start:multi-user",
|
||||
"bootstrap": "pnpm run install:all && pnpm run build:all && pnpm run start:all",
|
||||
"bootstrap:multi-user": "npm run install:all && npm run build:all:multi-user && pnpm run start:all:multi-user",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
24
mcp/plugin/.gitignore
vendored
24
mcp/plugin/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,21 +0,0 @@
|
||||
# Penpot MCP Plugin
|
||||
|
||||
This project contains a Penpot plugin that accompanies the Penpot MCP server.
|
||||
It connects to the MCP server via WebSocket, subsequently allowing the MCP
|
||||
server to execute tasks in Penpot using the Plugin API.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Dependencies
|
||||
|
||||
pnpm install
|
||||
|
||||
2. Build the Project
|
||||
|
||||
pnpm run build
|
||||
|
||||
3. Start a Local Development Server
|
||||
|
||||
pnpm run start
|
||||
|
||||
This will start a local development server at `http://localhost:4400`.
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Penpot plugin example</title>
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button>
|
||||
|
||||
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "mcp-plugin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite build --watch",
|
||||
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch",
|
||||
"build": "tsc && vite build",
|
||||
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@penpot/plugin-styles": "1.4.1",
|
||||
"@penpot/plugin-types": "1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.8",
|
||||
"vite-live-preview": "^0.3.2"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Penpot MCP Plugin",
|
||||
"code": "plugin.js",
|
||||
"description": "This plugin enables interaction with the Penpot MCP server",
|
||||
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
import { Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
|
||||
|
||||
export class PenpotUtils {
|
||||
/**
|
||||
* Generates an overview structure of the given shape,
|
||||
* providing its id, name and type, and recursively its children's attributes.
|
||||
* The `type` field indicates the type in the Penpot API.
|
||||
* If the shape has a layout system (flex or grid), includes layout information.
|
||||
*
|
||||
* @param shape - The root shape to generate the structure from
|
||||
* @param maxDepth - Optional maximum depth to traverse (leave undefined for unlimited)
|
||||
* @returns An object representing the shape structure
|
||||
*/
|
||||
public static shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): object {
|
||||
let children = undefined;
|
||||
if (maxDepth === undefined || maxDepth > 0) {
|
||||
if ("children" in shape && shape.children) {
|
||||
children = shape.children.map((child) =>
|
||||
this.shapeStructure(child, maxDepth === undefined ? undefined : maxDepth - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
type: shape.type,
|
||||
children: children,
|
||||
};
|
||||
|
||||
// add layout information if present
|
||||
if ("flex" in shape && shape.flex) {
|
||||
const flex: FlexLayout = shape.flex;
|
||||
result.layout = {
|
||||
type: "flex",
|
||||
dir: flex.dir,
|
||||
rowGap: flex.rowGap,
|
||||
columnGap: flex.columnGap,
|
||||
};
|
||||
} else if ("grid" in shape && shape.grid) {
|
||||
const grid: GridLayout = shape.grid;
|
||||
result.layout = {
|
||||
type: "grid",
|
||||
rows: grid.rows,
|
||||
columns: grid.columns,
|
||||
rowGap: grid.rowGap,
|
||||
columnGap: grid.columnGap,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all shapes that matches the given predicate in the given shape tree.
|
||||
*
|
||||
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
||||
* @param root - The root shape to start the search from (defaults to penpot.root)
|
||||
*/
|
||||
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
|
||||
let result = new Array<Shape>();
|
||||
|
||||
let find = function (shape: Shape | null) {
|
||||
if (!shape) {
|
||||
return;
|
||||
}
|
||||
if (predicate(shape)) {
|
||||
result.push(shape);
|
||||
}
|
||||
if ("children" in shape && shape.children) {
|
||||
for (let child of shape.children) {
|
||||
find(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
find(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first shape that matches the given predicate in the given shape tree.
|
||||
*
|
||||
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
||||
* @param root - The root shape to start the search from (if null, searches all pages)
|
||||
*/
|
||||
public static findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null {
|
||||
let find = function (shape: Shape | null): Shape | null {
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
if (predicate(shape)) {
|
||||
return shape;
|
||||
}
|
||||
if ("children" in shape && shape.children) {
|
||||
for (let child of shape.children) {
|
||||
let result = find(child);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (root === null) {
|
||||
const pages = penpot.currentFile?.pages;
|
||||
if (pages) {
|
||||
for (let page of pages) {
|
||||
let result = find(page.root);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return find(root);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a shape by its unique ID.
|
||||
*
|
||||
* @param id - The unique ID of the shape to find
|
||||
* @returns The shape with the matching ID, or null if not found
|
||||
*/
|
||||
public static findShapeById(id: string): Shape | null {
|
||||
return this.findShape((shape) => shape.id === id);
|
||||
}
|
||||
|
||||
public static findPage(predicate: (page: Page) => boolean): Page | null {
|
||||
let page = penpot.currentFile!.pages.find(predicate);
|
||||
return page || null;
|
||||
}
|
||||
|
||||
public static getPages(): { id: string; name: string }[] {
|
||||
return penpot.currentFile!.pages.map((page) => ({ id: page.id, name: page.name }));
|
||||
}
|
||||
|
||||
public static getPageById(id: string): Page | null {
|
||||
return this.findPage((page) => page.id === id);
|
||||
}
|
||||
|
||||
public static getPageByName(name: string): Page | null {
|
||||
return this.findPage((page) => page.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
public static getPageForShape(shape: Shape): Page | null {
|
||||
for (const page of penpot.currentFile!.pages) {
|
||||
if (page.getShapeById(shape.id)) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static generateCss(shape: Shape): string {
|
||||
const page = this.getPageForShape(shape);
|
||||
if (!page) {
|
||||
throw new Error("Shape is not part of any page");
|
||||
}
|
||||
penpot.openPage(page);
|
||||
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a child shape is fully contained within its parent's bounds.
|
||||
* Visual containment means all edges of the child are within the parent's bounding box.
|
||||
*
|
||||
* @param child - The child shape to check
|
||||
* @param parent - The parent shape to check against
|
||||
* @returns true if child is fully contained within parent bounds, false otherwise
|
||||
*/
|
||||
public static isContainedIn(child: Shape, parent: Shape): boolean {
|
||||
return (
|
||||
child.x >= parent.x &&
|
||||
child.y >= parent.y &&
|
||||
child.x + child.width <= parent.x + parent.width &&
|
||||
child.y + child.height <= parent.y + parent.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the position of a shape relative to its parent's position.
|
||||
* This is a convenience method since parentX and parentY are read-only properties.
|
||||
*
|
||||
* @param shape - The shape to position
|
||||
* @param parentX - The desired X position relative to the parent
|
||||
* @param parentY - The desired Y position relative to the parent
|
||||
* @throws Error if the shape has no parent
|
||||
*/
|
||||
public static setParentXY(shape: Shape, parentX: number, parentY: number): void {
|
||||
if (!shape.parent) {
|
||||
throw new Error("Shape has no parent - cannot set parent-relative position");
|
||||
}
|
||||
shape.x = shape.parent.x + parentX;
|
||||
shape.y = shape.parent.y + parentY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes all descendants of a shape by applying an evaluator function to each.
|
||||
* Only descendants for which the evaluator returns a non-null/non-undefined value are included in the result.
|
||||
* This is a general-purpose utility for validation, analysis, or collecting corrector functions.
|
||||
*
|
||||
* @param root - The root shape whose descendants to analyze
|
||||
* @param evaluator - Function called for each descendant with (root, descendant); return null/undefined to skip
|
||||
* @param maxDepth - Optional maximum depth to traverse (undefined for unlimited)
|
||||
* @returns Array of objects containing the shape and the evaluator's result
|
||||
*/
|
||||
public static analyzeDescendants<T>(
|
||||
root: Shape,
|
||||
evaluator: (root: Shape, descendant: Shape) => T | null | undefined,
|
||||
maxDepth: number | undefined = undefined
|
||||
): Array<{ shape: Shape; result: NonNullable<T> }> {
|
||||
const results: Array<{ shape: Shape; result: NonNullable<T> }> = [];
|
||||
|
||||
const traverse = (shape: Shape, currentDepth: number): void => {
|
||||
const result = evaluator(root, shape);
|
||||
if (result !== null && result !== undefined) {
|
||||
results.push({ shape, result: result as NonNullable<T> });
|
||||
}
|
||||
|
||||
if (maxDepth === undefined || currentDepth < maxDepth) {
|
||||
if ("children" in shape && shape.children) {
|
||||
for (const child of shape.children) {
|
||||
traverse(child, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start traversal with root's children (not root itself)
|
||||
if ("children" in root && root.children) {
|
||||
for (const child of root.children) {
|
||||
traverse(child, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string to a Uint8Array.
|
||||
* This is required because the Penpot plugin environment does not provide the atob function.
|
||||
*
|
||||
* @param base64 - The base64-encoded string to decode
|
||||
* @returns The decoded data as a Uint8Array
|
||||
*/
|
||||
public static atob(base64: string): Uint8Array {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
let bufferLength = base64.length * 0.75;
|
||||
if (base64[base64.length - 1] === "=") {
|
||||
bufferLength--;
|
||||
if (base64[base64.length - 2] === "=") {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(bufferLength);
|
||||
let p = 0;
|
||||
for (let i = 0; i < base64.length; i += 4) {
|
||||
const encoded1 = lookup[base64.charCodeAt(i)];
|
||||
const encoded2 = lookup[base64.charCodeAt(i + 1)];
|
||||
const encoded3 = lookup[base64.charCodeAt(i + 2)];
|
||||
const encoded4 = lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image.
|
||||
* The rectangle has the image's original proportions by default.
|
||||
* Optionally accepts position (x, y) and dimensions (width, height) parameters.
|
||||
* If only one dimension is provided, the other is calculated to maintain the image's aspect ratio.
|
||||
*
|
||||
* This function is used internally by the ImportImageTool in the MCP server.
|
||||
*
|
||||
* @param base64 - The base64-encoded image data
|
||||
* @param mimeType - The MIME type of the image (e.g., "image/png")
|
||||
* @param name - The name to assign to the newly created rectangle shape
|
||||
* @param x - The x-coordinate for positioning the rectangle (optional)
|
||||
* @param y - The y-coordinate for positioning the rectangle (optional)
|
||||
* @param width - The desired width of the rectangle (optional)
|
||||
* @param height - The desired height of the rectangle (optional)
|
||||
*/
|
||||
public static async importImage(
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
name: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
width: number | undefined,
|
||||
height: number | undefined
|
||||
): Promise<Rectangle> {
|
||||
// convert base64 to Uint8Array
|
||||
const bytes = PenpotUtils.atob(base64);
|
||||
|
||||
// upload the image data to Penpot
|
||||
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
|
||||
|
||||
// create a rectangle shape
|
||||
const rect = penpot.createRectangle();
|
||||
rect.name = name;
|
||||
|
||||
// calculate dimensions
|
||||
let rectWidth, rectHeight;
|
||||
const hasWidth = width !== undefined;
|
||||
const hasHeight = height !== undefined;
|
||||
|
||||
if (hasWidth && hasHeight) {
|
||||
// both width and height provided - use them directly
|
||||
rectWidth = width;
|
||||
rectHeight = height;
|
||||
} else if (hasWidth) {
|
||||
// only width provided - maintain aspect ratio
|
||||
rectWidth = width;
|
||||
rectHeight = rectWidth * (imageData.height / imageData.width);
|
||||
} else if (hasHeight) {
|
||||
// only height provided - maintain aspect ratio
|
||||
rectHeight = height;
|
||||
rectWidth = rectHeight * (imageData.width / imageData.height);
|
||||
} else {
|
||||
// neither provided - use original dimensions
|
||||
rectWidth = imageData.width;
|
||||
rectHeight = imageData.height;
|
||||
}
|
||||
|
||||
// set rectangle dimensions
|
||||
rect.resize(rectWidth, rectHeight);
|
||||
|
||||
// set position if provided
|
||||
if (x !== undefined) {
|
||||
rect.x = x;
|
||||
}
|
||||
if (y !== undefined) {
|
||||
rect.y = y;
|
||||
}
|
||||
|
||||
// apply the image as a fill
|
||||
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the given shape (or its fill) to BASE64 image data.
|
||||
*
|
||||
* This function is used internally by the ExportImageTool in the MCP server.
|
||||
*
|
||||
* @param shape - The shape whose image data to export
|
||||
* @param mode - Either "shape" (to export the entire shape, including descendants) or "fill"
|
||||
* to export the shape's raw fill image data
|
||||
* @param asSVG - Whether to export as SVG rather than as a pixel image (only supported for mode "shape")
|
||||
* @returns A byte array containing the exported image data.
|
||||
* - For mode="shape", it will be PNG or SVG data depending on the value of `asSVG`.
|
||||
* - For mode="fill", it will be whatever format the fill image is stored in.
|
||||
*/
|
||||
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
|
||||
switch (mode) {
|
||||
case "shape":
|
||||
return shape.export({ type: asSVG ? "svg" : "png" });
|
||||
case "fill":
|
||||
if (asSVG) {
|
||||
throw new Error("Image fills cannot be exported as SVG");
|
||||
}
|
||||
// check whether the shape has the `fills` member
|
||||
if (!("fills" in shape)) {
|
||||
throw new Error("Shape with `fills` member is required for fill export mode");
|
||||
}
|
||||
// find first fill that has fillImage
|
||||
const fills: Fill[] = (shape as any).fills;
|
||||
for (const fill of fills) {
|
||||
if (fill.fillImage) {
|
||||
const imageData = fill.fillImage;
|
||||
return imageData.data();
|
||||
}
|
||||
}
|
||||
throw new Error("No fill with image data found in the shape");
|
||||
default:
|
||||
throw new Error(`Unsupported export mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Represents a task received from the MCP server in the Penpot MCP plugin
|
||||
*/
|
||||
export class Task<TParams = any> {
|
||||
public isResponseSent: boolean = false;
|
||||
|
||||
/**
|
||||
* @param requestId Unique identifier for the task request
|
||||
* @param taskType The type of the task to execute
|
||||
* @param params Task parameters/arguments
|
||||
*/
|
||||
constructor(
|
||||
public requestId: string,
|
||||
public taskType: string,
|
||||
public params: TParams
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sends a task response back to the MCP server.
|
||||
*/
|
||||
protected sendResponse(success: boolean, data: any = undefined, error: any = undefined): void {
|
||||
if (this.isResponseSent) {
|
||||
console.error("Response already sent for task:", this.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: "task-response",
|
||||
response: {
|
||||
id: this.requestId,
|
||||
success: success,
|
||||
data: data,
|
||||
error: error,
|
||||
},
|
||||
};
|
||||
|
||||
// Send to main.ts which will forward to MCP server via WebSocket
|
||||
penpot.ui.sendMessage(response);
|
||||
console.log("Sent task response:", response);
|
||||
this.isResponseSent = true;
|
||||
}
|
||||
|
||||
public sendSuccess(data: any = undefined): void {
|
||||
this.sendResponse(true, data);
|
||||
}
|
||||
|
||||
public sendError(error: string): void {
|
||||
this.sendResponse(false, undefined, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for task handlers in the Penpot MCP plugin.
|
||||
*
|
||||
* @template TParams - The type of parameters this handler expects
|
||||
*/
|
||||
export abstract class TaskHandler<TParams = any> {
|
||||
/** The task type this handler is responsible for */
|
||||
abstract readonly taskType: string;
|
||||
|
||||
/**
|
||||
* Checks if this handler can process the given task.
|
||||
*
|
||||
* @param task - The task identifier to check
|
||||
* @returns True if this handler applies to the given task
|
||||
*/
|
||||
isApplicableTo(task: Task): boolean {
|
||||
return this.taskType === task.taskType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the task with the provided parameters.
|
||||
*
|
||||
* @param task - The task to be handled
|
||||
*/
|
||||
abstract handle(task: Task<TParams>): Promise<void>;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import "./style.css";
|
||||
|
||||
// get the current theme from the URL
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
document.body.dataset.theme = searchParams.get("theme") ?? "light";
|
||||
|
||||
// Determine whether multi-user mode is enabled based on URL parameters
|
||||
const isMultiUserMode = searchParams.get("multiUser") === "true";
|
||||
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
|
||||
|
||||
// WebSocket connection management
|
||||
let ws: WebSocket | null = null;
|
||||
const statusElement = document.getElementById("connection-status");
|
||||
|
||||
/**
|
||||
* Updates the connection status display element.
|
||||
*
|
||||
* @param status - the base status text to display
|
||||
* @param isConnectedState - whether the connection is in a connected state (affects color)
|
||||
* @param message - optional additional message to append to the status
|
||||
*/
|
||||
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
|
||||
if (statusElement) {
|
||||
const displayText = message ? `${status}: ${message}` : status;
|
||||
statusElement.textContent = displayText;
|
||||
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a task response back to the MCP server via WebSocket.
|
||||
*
|
||||
* @param response - The response containing task ID and result
|
||||
*/
|
||||
function sendTaskResponse(response: any): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(response));
|
||||
console.log("Sent response to MCP server:", response);
|
||||
} else {
|
||||
console.error("WebSocket not connected, cannot send response");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a WebSocket connection to the MCP server.
|
||||
*/
|
||||
function connectToMcpServer(): void {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
updateConnectionStatus("Already connected", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
|
||||
if (isMultiUserMode) {
|
||||
// TODO obtain proper userToken from penpot
|
||||
const userToken = "dummyToken";
|
||||
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
|
||||
}
|
||||
ws = new WebSocket(wsUrl);
|
||||
updateConnectionStatus("Connecting...", false);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to MCP server");
|
||||
updateConnectionStatus("Connected to MCP server", true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log("Received from MCP server:", event.data);
|
||||
try {
|
||||
const request = JSON.parse(event.data);
|
||||
// Forward the task request to the plugin for execution
|
||||
parent.postMessage(request, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
console.log("Disconnected from MCP server");
|
||||
const message = event.reason || undefined;
|
||||
updateConnectionStatus("Disconnected", false, message);
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
// note: WebSocket error events typically don't contain detailed error messages
|
||||
updateConnectionStatus("Connection error", false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to MCP server:", error);
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
updateConnectionStatus("Connection failed", false, message);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
|
||||
connectToMcpServer();
|
||||
});
|
||||
|
||||
// Listen plugin.ts messages
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.source === "penpot") {
|
||||
document.body.dataset.theme = event.data.theme;
|
||||
} else if (event.data.type === "task-response") {
|
||||
// Forward task response back to MCP server
|
||||
sendTaskResponse(event.data.response);
|
||||
}
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
|
||||
import { Task, TaskHandler } from "./TaskHandler";
|
||||
|
||||
/**
|
||||
* Registry of all available task handlers.
|
||||
*/
|
||||
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
|
||||
|
||||
// Determine whether multi-user mode is enabled based on build-time configuration
|
||||
declare const IS_MULTI_USER_MODE: boolean;
|
||||
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
|
||||
|
||||
// Open the plugin UI (main.ts)
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
|
||||
|
||||
// Handle messages
|
||||
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
|
||||
// Handle plugin task requests
|
||||
if (typeof message === "object" && message.task && message.id) {
|
||||
handlePluginTaskRequest(message).catch((error) => {
|
||||
console.error("Error in handlePluginTaskRequest:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles plugin task requests received from the MCP server via WebSocket.
|
||||
*
|
||||
* @param request - The task request containing ID, task type and parameters
|
||||
*/
|
||||
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
|
||||
console.log("Executing plugin task:", request.task, request.params);
|
||||
const task = new Task(request.id, request.task, request.params);
|
||||
|
||||
// Find the appropriate handler
|
||||
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
// Cast the params to the expected type and handle the task
|
||||
console.log("Processing task with handler:", handler);
|
||||
await handler.handle(task);
|
||||
|
||||
// check whether a response was sent and send a generic success if not
|
||||
if (!task.isResponseSent) {
|
||||
console.warn("Handler did not send a response, sending generic success.");
|
||||
task.sendSuccess("Task completed without a specific response.");
|
||||
}
|
||||
|
||||
console.log("Task handled successfully:", task);
|
||||
} catch (error) {
|
||||
console.error("Error handling task:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
task.sendError(`Error handling task: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.error("Unknown plugin task:", request.task);
|
||||
task.sendError(`Unknown task type: ${request.task}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle theme change in the iframe
|
||||
penpot.on("themechange", (theme) => {
|
||||
penpot.ui.sendMessage({
|
||||
source: "penpot",
|
||||
type: "themechange",
|
||||
theme,
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
@import "@penpot/plugin-styles/styles.css";
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Task, TaskHandler } from "../TaskHandler";
|
||||
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src";
|
||||
import { PenpotUtils } from "../PenpotUtils.ts";
|
||||
|
||||
/**
|
||||
* Console implementation that captures all log output for code execution.
|
||||
*
|
||||
* Provides the same interface as the native console object but appends
|
||||
* all output to an internal log string that can be retrieved.
|
||||
*/
|
||||
class ExecuteCodeTaskConsole {
|
||||
/**
|
||||
* Accumulated log output from all console method calls.
|
||||
*/
|
||||
private logOutput: string = "";
|
||||
|
||||
/**
|
||||
* Resets the accumulated log output to empty string.
|
||||
* Should be called before each code execution to start with clean logs.
|
||||
*/
|
||||
resetLog(): void {
|
||||
this.logOutput = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the accumulated log output from all console method calls.
|
||||
* @returns The complete log output as a string
|
||||
*/
|
||||
getLog(): string {
|
||||
return this.logOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a formatted message to the log output.
|
||||
* @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR")
|
||||
* @param args - Arguments to log, will be stringified and joined
|
||||
*/
|
||||
private appendToLog(level: string, ...args: any[]): void {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)))
|
||||
.join(" ");
|
||||
this.logOutput += `[${level}] ${message}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message to the captured output.
|
||||
*/
|
||||
log(...args: any[]): void {
|
||||
this.appendToLog("LOG", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message to the captured output.
|
||||
*/
|
||||
warn(...args: any[]): void {
|
||||
this.appendToLog("WARN", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message to the captured output.
|
||||
*/
|
||||
error(...args: any[]): void {
|
||||
this.appendToLog("ERROR", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an informational message to the captured output.
|
||||
*/
|
||||
info(...args: any[]): void {
|
||||
this.appendToLog("INFO", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message to the captured output.
|
||||
*/
|
||||
debug(...args: any[]): void {
|
||||
this.appendToLog("DEBUG", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message with trace information to the captured output.
|
||||
*/
|
||||
trace(...args: any[]): void {
|
||||
this.appendToLog("TRACE", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a table to the captured output (simplified as JSON).
|
||||
*/
|
||||
table(data: any): void {
|
||||
this.appendToLog("TABLE", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a timer (simplified implementation that just logs).
|
||||
*/
|
||||
time(label?: string): void {
|
||||
this.appendToLog("TIME", `Timer started: ${label || "default"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a timer (simplified implementation that just logs).
|
||||
*/
|
||||
timeEnd(label?: string): void {
|
||||
this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages in a group (simplified to just log the label).
|
||||
*/
|
||||
group(label?: string): void {
|
||||
this.appendToLog("GROUP", label || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages in a collapsed group (simplified to just log the label).
|
||||
*/
|
||||
groupCollapsed(label?: string): void {
|
||||
this.appendToLog("GROUP_COLLAPSED", label || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current group (simplified implementation).
|
||||
*/
|
||||
groupEnd(): void {
|
||||
this.appendToLog("GROUP_END", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the console (no-op in this implementation since we want to capture logs).
|
||||
*/
|
||||
clear(): void {
|
||||
// intentionally empty - we don't want to clear captured logs
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts occurrences of calls with the same label (simplified implementation).
|
||||
*/
|
||||
count(label?: string): void {
|
||||
this.appendToLog("COUNT", label || "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the count for a label (simplified implementation).
|
||||
*/
|
||||
countReset(label?: string): void {
|
||||
this.appendToLog("COUNT_RESET", label || "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an assertion (simplified to just log if condition is false).
|
||||
*/
|
||||
assert(condition: boolean, ...args: any[]): void {
|
||||
if (!condition) {
|
||||
this.appendToLog("ASSERT", ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task handler for executing JavaScript code in the plugin context.
|
||||
*
|
||||
* Maintains a persistent context object that preserves state between code executions
|
||||
* and captures all console output during execution.
|
||||
*/
|
||||
export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
readonly taskType = "executeCode";
|
||||
|
||||
/**
|
||||
* Persistent context object that maintains state between code executions.
|
||||
* Contains the penpot API, storage object, and custom console implementation.
|
||||
*/
|
||||
private readonly context: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// initialize context, making penpot, penpotUtils, storage and the custom console available
|
||||
this.context = {
|
||||
penpot: penpot,
|
||||
storage: {},
|
||||
console: new ExecuteCodeTaskConsole(),
|
||||
penpotUtils: PenpotUtils,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(task: Task<ExecuteCodeTaskParams>): Promise<void> {
|
||||
if (!task.params.code) {
|
||||
task.sendError("executeCode task requires 'code' parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.console.resetLog();
|
||||
|
||||
const context = this.context;
|
||||
const code = task.params.code;
|
||||
|
||||
let result: any = await (async (ctx) => {
|
||||
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
|
||||
return fn(...Object.values(ctx));
|
||||
})(context);
|
||||
|
||||
console.log("Code execution result:", result);
|
||||
|
||||
// return result and captured log
|
||||
let resultData: ExecuteCodeTaskResultData<any> = {
|
||||
result: result,
|
||||
log: this.context.console.getLog(),
|
||||
};
|
||||
task.sendSuccess(resultData);
|
||||
}
|
||||
}
|
||||
4
mcp/plugin/src/vite-env.d.ts
vendored
4
mcp/plugin/src/vite-env.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const IS_MULTI_USER_MODE: boolean;
|
||||
declare const PENPOT_MCP_WEBSOCKET_URL: string;
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules/@penpot"],
|
||||
"types": ["plugin-types"],
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import livePreview from "vite-live-preview";
|
||||
|
||||
// Debug: Log the environment variables
|
||||
console.log("MULTI_USER_MODE env:", process.env.MULTI_USER_MODE);
|
||||
console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(process.env.MULTI_USER_MODE === "true"));
|
||||
|
||||
const serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS || "localhost";
|
||||
const websocketPort = process.env.PENPOT_MCP_WEBSOCKET_PORT || "4402";
|
||||
const websocketUrl = `ws://${serverAddress}:${websocketPort}`;
|
||||
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(websocketUrl));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
livePreview({
|
||||
reload: true,
|
||||
config: {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
plugin: "src/plugin.ts",
|
||||
index: "./index.html",
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 4400,
|
||||
cors: true,
|
||||
allowedHosts: process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS
|
||||
? process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS.split(",").map((h) => h.trim())
|
||||
: [],
|
||||
},
|
||||
define: {
|
||||
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
|
||||
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(websocketUrl),
|
||||
},
|
||||
});
|
||||
2665
mcp/pnpm-lock.yaml
generated
2665
mcp/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
#shamefullyHoist: true
|
||||
|
||||
packages:
|
||||
- "./common"
|
||||
- "./server"
|
||||
- "./plugin"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# NOTE: this script should be called from the parent directory to
|
||||
# properly work
|
||||
|
||||
set -ex
|
||||
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
# Ensure clean working directory
|
||||
rm -rf dist;
|
||||
rm -rf node_modules;
|
||||
rm -rf server/dist;
|
||||
rm -rf server/node_modules;
|
||||
|
||||
pushd types-generator
|
||||
pixi install;
|
||||
pixi run python prepare_api_docs.py;
|
||||
popd
|
||||
|
||||
pnpm -r --filter "!mcp-plugin" install;
|
||||
pnpm -r --filter "mcp-server" run build:multi-user;
|
||||
|
||||
rsync -avr server/dist/ ./dist/;
|
||||
rsync -avr server/data/ ./dist/data/;
|
||||
|
||||
cp server/package.json ./dist/;
|
||||
cp server/pnpm-lock.yaml ./dist/;
|
||||
|
||||
cat <<EOF | tee ./dist/setup
|
||||
#/usr/bin/env bash
|
||||
set -e;
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install -P
|
||||
EOF
|
||||
|
||||
chmod +x ./dist/setup;
|
||||
0
mcp/server/.gitignore
vendored
0
mcp/server/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Penpot MCP Server
|
||||
|
||||
A Model Context Protocol (MCP) server that provides Penpot integration
|
||||
capabilities for AI clients supporting the model context protocol (MCP).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Dependencies
|
||||
|
||||
pnpm install
|
||||
|
||||
2. Build the Project
|
||||
|
||||
pnpm run build
|
||||
|
||||
3. Run the Server
|
||||
|
||||
pnpm run start
|
||||
|
||||
|
||||
## Penpot Plugin API REPL
|
||||
|
||||
The MCP server includes a REPL interface for testing Penpot Plugin API calls.
|
||||
To use it, connect to the URL reported at startup.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,253 +0,0 @@
|
||||
# Prompts configuration for Penpot MCP Server
|
||||
# This file contains various prompts and instructions that can be used by the server
|
||||
|
||||
initial_instructions: |
|
||||
You have access to Penpot tools in order to interact with a Penpot design project directly.
|
||||
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
|
||||
|
||||
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Executing Code
|
||||
|
||||
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
|
||||
directly in the connected project.
|
||||
|
||||
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
|
||||
|
||||
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
|
||||
the `penpot_api_info` tool.
|
||||
|
||||
This is the full list of types/interfaces in the Penpot API: $api_types
|
||||
|
||||
You use the `storage` object extensively to store data and utility functions you define across tool calls.
|
||||
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
|
||||
|
||||
# The Structure of Penpot Designs
|
||||
|
||||
A Penpot design ultimately consists of shapes.
|
||||
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
|
||||
Shapes in a Penpot design are organized hierarchically.
|
||||
At the top level, a design project contains one or more `Page` objects.
|
||||
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
|
||||
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
|
||||
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
|
||||
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
|
||||
`ShapeBase` is a base type most shapes build upon.
|
||||
|
||||
# Core Shape Properties and Methods
|
||||
|
||||
**Type**:
|
||||
Any given shape contains information on the concrete type via its `type` field.
|
||||
|
||||
**Position and Dimensions**:
|
||||
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
|
||||
These are writable - set them directly to position shapes.
|
||||
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
||||
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
||||
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
||||
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
|
||||
|
||||
**Other Writable Properties**:
|
||||
* `name` - Shape name
|
||||
* `fills`, `strokes` - Styling properties
|
||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
||||
|
||||
**Z-Order**:
|
||||
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
||||
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
||||
(i.e. add background shapes first, then foreground shapes later).
|
||||
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
|
||||
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
||||
and, for precise control, `setParentIndex(index)` (0-based).
|
||||
|
||||
**Modification Methods**:
|
||||
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
|
||||
* `rotate(angle, center?)` - Rotate shape
|
||||
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting)
|
||||
|
||||
**Hierarchical Structure**:
|
||||
* `parent` - The parent shape (null for root shapes)
|
||||
Note: Hierarchical nesting does not necessarily imply visual containment
|
||||
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
|
||||
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
|
||||
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
|
||||
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
||||
- Automatically removes the shape from its old parent
|
||||
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
||||
|
||||
# Images
|
||||
|
||||
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
|
||||
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
|
||||
Use the `export_shape` and `import_image` tools to export and import images.
|
||||
|
||||
# Layout Systems
|
||||
|
||||
Boards can have layout systems that automatically control the positioning and spacing of their children:
|
||||
|
||||
* **Flex Layout**: A flexbox-style layout system
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessibly via `board.flex`;
|
||||
Check with: `if (board.flex) { ... }`
|
||||
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates;
|
||||
appending or inserting children automatically positions them according to the layout rules.
|
||||
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
|
||||
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
|
||||
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
|
||||
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
|
||||
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
|
||||
or dir="row".
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions
|
||||
|
||||
* **Grid Layout**: A CSS grid-style layout system
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessibly via `board.grid`;
|
||||
Check with: `if (board.grid) { ... }`
|
||||
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
|
||||
- Children are positioned via 1-based row/column indices
|
||||
- Add to grid via `board.flex.appendChild(shape, row, column)`
|
||||
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
|
||||
|
||||
* When working with boards:
|
||||
- ALWAYS check if the board has a layout system before attempting to reposition children
|
||||
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
|
||||
- Layout systems override manual positioning of children
|
||||
|
||||
# Text Elements
|
||||
|
||||
The rendered content of `Text` element is given by the `characters` property.
|
||||
|
||||
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
||||
it only changes the formal bounding box; if the text does not fit it, it will overflow.
|
||||
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
||||
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
|
||||
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
||||
|
||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||
|
||||
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
|
||||
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
|
||||
If it is unclear which elements to work on, you can ask the user to select them for you.
|
||||
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
|
||||
* `penpot.root` provides the root shape of the currently active page.
|
||||
* Generation of CSS content for elements via `penpot.generateStyle`
|
||||
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
|
||||
|
||||
For example, to generate CSS for the currently selected elements, you can execute this:
|
||||
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
|
||||
|
||||
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
|
||||
* getPages(): { id: string; name: string }[]
|
||||
* getPageById(id: string): Page | null
|
||||
* getPageByName(name: string): Page | null
|
||||
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
|
||||
Generates an overview structure of the given shape.
|
||||
- children: recursive, limited by maxDepth
|
||||
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
|
||||
* findShapeById(id: string): Shape | null
|
||||
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
|
||||
If no root is provided, search globally (in all pages).
|
||||
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
|
||||
* isContainedIn(shape: Shape, container: Shape): boolean
|
||||
Returns true iff shape is fully within the container's geometric bounds.
|
||||
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
|
||||
* setParentXY(shape: Shape, parentX: number, parentY: number): void
|
||||
Sets shape position relative to its parent (since parentX/parentY are read-only)
|
||||
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
|
||||
General-purpose utility for analyzing/validating descendants
|
||||
Calls evaluator on each descendant; collects non-null/undefined results
|
||||
Powerful pattern: evaluator can return corrector functions or diagnostic data
|
||||
|
||||
General pointers for working with Penpot designs:
|
||||
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
|
||||
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
|
||||
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
|
||||
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
|
||||
|
||||
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
|
||||
* Find all images:
|
||||
const images = penpotUtils.findShapes(
|
||||
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
|
||||
penpot.root
|
||||
);
|
||||
* Find text elements:
|
||||
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
|
||||
* Find (the first) shape with a given name:
|
||||
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
|
||||
* Get structure of current selection:
|
||||
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
|
||||
* Find shapes in current selection/board:
|
||||
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
|
||||
* Validate/analyze descendants (returning corrector functions):
|
||||
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
const xMod = shape.parentX % 4;
|
||||
if (xMod !== 0) {
|
||||
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
|
||||
}
|
||||
});
|
||||
fixes.forEach(f => f.result()); // Apply all fixes
|
||||
* Find containment violations:
|
||||
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
|
||||
});
|
||||
Always validate against the root container that is supposed to contain the shapes.
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
|
||||
# Asset Libraries
|
||||
|
||||
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
|
||||
They enable design systems and consistent styling across projects.
|
||||
Each Penpot file has its own local library and can connect to external shared libraries.
|
||||
|
||||
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
|
||||
* `penpot.library.local` (type: `Library`) - The current file's own library
|
||||
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
|
||||
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
|
||||
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
|
||||
|
||||
Each `Library` object has:
|
||||
* `id: string`
|
||||
* `name: string`
|
||||
* `components: LibraryComponent[]` - Array of components
|
||||
* `colors: LibraryColor[]` - Array of colors
|
||||
* `typographies: LibraryTypography[]` - Array of typographies
|
||||
|
||||
Using library components:
|
||||
* find a component in the library by name:
|
||||
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
|
||||
* create a new instance of the component on the current page:
|
||||
const instance: Shape = component.instance();
|
||||
This returns a `Shape` (often a `Board` containing child elements).
|
||||
After instantiation, modify the instance's properties as desired.
|
||||
* get the reference to the main component shape:
|
||||
const mainShape: Shape = component.mainInstance();
|
||||
|
||||
Adding assets to a library:
|
||||
* const newColor: LibraryColor = penpot.library.local.createColor();
|
||||
newColor.name = 'Brand Primary';
|
||||
newColor.color = '#0066FF';
|
||||
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
||||
newTypo.name = 'Heading Large';
|
||||
// Set typography properties...
|
||||
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
||||
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
||||
newComponent.name = 'My Button';
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Penpot integration",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp",
|
||||
"build": "pnpm run build:server && cp -r src/static dist/static",
|
||||
"build:multi-user": "pnpm run build",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"start": "node dist/index.js",
|
||||
"start:multi-user": "node dist/index.js --multi-user",
|
||||
"start:dev": "node --import ts-node/register src/index.ts",
|
||||
"start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"penpot",
|
||||
"server"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.24.0",
|
||||
"@penpot/mcp-common": "workspace:../common",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"express": "^5.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"penpot-mcp": "file:..",
|
||||
"pino": "^9.10.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.25.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
}
|
||||
}
|
||||
2840
mcp/server/pnpm-lock.yaml
generated
2840
mcp/server/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Represents a single type/interface defined in the Penpot API
|
||||
*/
|
||||
export class ApiType {
|
||||
private readonly name: string;
|
||||
private readonly overview: string;
|
||||
private readonly members: Record<string, Record<string, string>>;
|
||||
private cachedFullText: string | null = null;
|
||||
|
||||
constructor(name: string, overview: string, members: Record<string, Record<string, string>>) {
|
||||
this.name = name;
|
||||
this.overview = overview;
|
||||
this.members = members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original name of this API type.
|
||||
*/
|
||||
getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the overview text of this API type (which all signature/type declarations)
|
||||
*/
|
||||
getOverviewText() {
|
||||
return this.overview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single markdown text document from all parts of this API type.
|
||||
*
|
||||
* The full text is cached within the object for performance.
|
||||
*/
|
||||
getFullText(): string {
|
||||
if (this.cachedFullText === null) {
|
||||
let text = this.overview;
|
||||
|
||||
for (const [memberType, memberEntries] of Object.entries(this.members)) {
|
||||
text += `\n\n## ${memberType}\n`;
|
||||
|
||||
for (const [memberName, memberDescription] of Object.entries(memberEntries)) {
|
||||
text += `\n### ${memberName}\n\n${memberDescription}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedFullText = text;
|
||||
}
|
||||
|
||||
return this.cachedFullText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the description of the member with the given name.
|
||||
*
|
||||
* The member type doesn't matter for the search, as member names are unique
|
||||
* across all member types within a single API type.
|
||||
*/
|
||||
getMember(memberName: string): string | null {
|
||||
for (const memberEntries of Object.values(this.members)) {
|
||||
if (memberName in memberEntries) {
|
||||
return memberEntries[memberName];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and manages API documentation from YAML files.
|
||||
*
|
||||
* This class provides case-insensitive access to API type documentation
|
||||
* loaded from the data/api_types.yml file.
|
||||
*/
|
||||
export class ApiDocs {
|
||||
private readonly apiTypes: Map<string, ApiType> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new ApiDocs instance and loads the API types from the YAML file.
|
||||
*/
|
||||
constructor() {
|
||||
this.loadApiTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads API types from the data/api_types.yml file.
|
||||
*/
|
||||
private loadApiTypes(): void {
|
||||
const yamlPath = path.join(process.cwd(), "data", "api_types.yml");
|
||||
const yamlContent = fs.readFileSync(yamlPath, "utf8");
|
||||
const data = yaml.load(yamlContent) as Record<string, any>;
|
||||
|
||||
for (const [typeName, typeData] of Object.entries(data)) {
|
||||
const overview = typeData.overview || "";
|
||||
const members = typeData.members || {};
|
||||
|
||||
const apiType = new ApiType(typeName, overview, members);
|
||||
|
||||
// store with lower-case key for case-insensitive retrieval
|
||||
this.apiTypes.set(typeName.toLowerCase(), apiType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an API type by name (case-insensitive).
|
||||
*/
|
||||
getType(typeName: string): ApiType | null {
|
||||
return this.apiTypes.get(typeName.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available type names.
|
||||
*/
|
||||
getTypeNames(): string[] {
|
||||
return Array.from(this.apiTypes.values()).map((type) => type.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of loaded API types.
|
||||
*/
|
||||
getTypeCount(): number {
|
||||
return this.apiTypes.size;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import yaml from "js-yaml";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Interface defining the structure of the prompts configuration file.
|
||||
*/
|
||||
export interface PromptsConfig {
|
||||
/** Initial instructions displayed when the server starts or connects to a client */
|
||||
initial_instructions: string;
|
||||
[key: string]: any; // Allow for future extension with additional prompt types
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration loader for prompts and server settings.
|
||||
*
|
||||
* Handles loading and parsing of YAML configuration files,
|
||||
* providing type-safe access to configuration values with
|
||||
* appropriate fallbacks for missing files or values.
|
||||
*/
|
||||
export class ConfigurationLoader {
|
||||
private readonly logger = createLogger("ConfigurationLoader");
|
||||
private readonly baseDir: string;
|
||||
private promptsConfig: PromptsConfig | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new configuration loader instance.
|
||||
*
|
||||
* @param baseDir - Base directory for resolving configuration file paths
|
||||
*/
|
||||
constructor(baseDir?: string) {
|
||||
// Default to the directory containing this module
|
||||
this.baseDir = baseDir || dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the prompts configuration from the YAML file.
|
||||
*
|
||||
* Reads and parses the prompts.yml file, providing cached access
|
||||
* to configuration values on subsequent calls.
|
||||
*
|
||||
* @returns The parsed prompts configuration object
|
||||
*/
|
||||
public getPromptsConfig(): PromptsConfig {
|
||||
if (this.promptsConfig !== null) {
|
||||
return this.promptsConfig;
|
||||
}
|
||||
|
||||
const promptsPath = join(this.baseDir, "..", "data", "prompts.yml");
|
||||
|
||||
if (!existsSync(promptsPath)) {
|
||||
throw new Error(`Prompts configuration file not found at ${promptsPath}, using defaults`);
|
||||
}
|
||||
|
||||
const fileContent = readFileSync(promptsPath, "utf8");
|
||||
const parsedConfig = yaml.load(fileContent) as PromptsConfig;
|
||||
|
||||
this.promptsConfig = parsedConfig || {};
|
||||
this.logger.info(`Loaded prompts configuration from ${promptsPath}`);
|
||||
|
||||
return this.promptsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the initial instructions for the MCP server.
|
||||
*
|
||||
* @returns The initial instructions string, or undefined if not configured
|
||||
*/
|
||||
public getInitialInstructions(): string {
|
||||
const config = this.getPromptsConfig();
|
||||
return config.initial_instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the configuration from disk.
|
||||
*
|
||||
* Forces a fresh read of the configuration file on the next access,
|
||||
* useful for development or when configuration files are updated at runtime.
|
||||
*/
|
||||
public reloadConfiguration(): void {
|
||||
this.promptsConfig = null;
|
||||
this.logger.info("Configuration cache cleared, will reload on next access");
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { ExecuteCodeTool } from "./tools/ExecuteCodeTool";
|
||||
import { PluginBridge } from "./PluginBridge";
|
||||
import { ConfigurationLoader } from "./ConfigurationLoader";
|
||||
import { createLogger } from "./logger";
|
||||
import { Tool } from "./Tool";
|
||||
import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
|
||||
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
|
||||
import { ExportShapeTool } from "./tools/ExportShapeTool";
|
||||
import { ImportImageTool } from "./tools/ImportImageTool";
|
||||
import { ReplServer } from "./ReplServer";
|
||||
import { ApiDocs } from "./ApiDocs";
|
||||
|
||||
/**
|
||||
* Session context for request-scoped data.
|
||||
*/
|
||||
export interface SessionContext {
|
||||
userToken?: string;
|
||||
}
|
||||
|
||||
export class PenpotMcpServer {
|
||||
private readonly logger = createLogger("PenpotMcpServer");
|
||||
private readonly server: McpServer;
|
||||
private readonly tools: Map<string, Tool<any>>;
|
||||
public readonly configLoader: ConfigurationLoader;
|
||||
private app: any;
|
||||
public readonly pluginBridge: PluginBridge;
|
||||
private readonly replServer: ReplServer;
|
||||
private apiDocs: ApiDocs;
|
||||
|
||||
/**
|
||||
* Manages session-specific context, particularly user tokens for each request.
|
||||
*/
|
||||
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
|
||||
|
||||
private readonly transports = {
|
||||
streamable: {} as Record<string, StreamableHTTPServerTransport>,
|
||||
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
|
||||
};
|
||||
|
||||
private readonly port: number;
|
||||
private readonly webSocketPort: number;
|
||||
private readonly replPort: number;
|
||||
private readonly listenAddress: string;
|
||||
/**
|
||||
* the address (domain name or IP address) via which clients can reach the MCP server
|
||||
*/
|
||||
public readonly serverAddress: string;
|
||||
|
||||
constructor(private isMultiUser: boolean = false) {
|
||||
// read port configuration from environment variables
|
||||
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
|
||||
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
|
||||
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
|
||||
this.listenAddress = process.env.PENPOT_MCP_SERVER_LISTEN_ADDRESS ?? "localhost";
|
||||
this.serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS ?? "localhost";
|
||||
|
||||
this.configLoader = new ConfigurationLoader();
|
||||
this.apiDocs = new ApiDocs();
|
||||
|
||||
this.server = new McpServer(
|
||||
{
|
||||
name: "penpot-mcp-server",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
instructions: this.getInitialInstructions(),
|
||||
}
|
||||
);
|
||||
|
||||
this.tools = new Map<string, Tool<any>>();
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
|
||||
|
||||
this.registerTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the server is running in multi-user mode,
|
||||
* where user tokens are required for authentication.
|
||||
*/
|
||||
public isMultiUserMode(): boolean {
|
||||
return this.isMultiUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the server is running in remote mode.
|
||||
*
|
||||
* In remote mode, the server is not assumed to be accessed only by a local user on the same machine,
|
||||
* with corresponding limitations being enforced.
|
||||
* Remote mode can be explicitly enabled by setting the environment variable PENPOT_MCP_REMOTE_MODE
|
||||
* to "true". Enabling multi-user mode forces remote mode, regardless of the value of the environment
|
||||
* variable.
|
||||
*/
|
||||
public isRemoteMode(): boolean {
|
||||
const isRemoteModeRequested: boolean = process.env.PENPOT_MCP_REMOTE_MODE === "true";
|
||||
return this.isMultiUserMode() || isRemoteModeRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether file system access is enabled for MCP tools.
|
||||
* Access is enabled only in local mode, where the file system is assumed
|
||||
* to belong to the user running the server locally.
|
||||
*/
|
||||
public isFileSystemAccessEnabled(): boolean {
|
||||
return !this.isRemoteMode();
|
||||
}
|
||||
|
||||
public getInitialInstructions(): string {
|
||||
let instructions = this.configLoader.getInitialInstructions();
|
||||
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
|
||||
return instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current session context.
|
||||
*
|
||||
* @returns The session context for the current request, or undefined if not in a request context
|
||||
*/
|
||||
public getSessionContext(): SessionContext | undefined {
|
||||
return this.sessionContext.getStore();
|
||||
}
|
||||
|
||||
private registerTools(): void {
|
||||
// Create relevant tool instances (depending on file system access)
|
||||
const toolInstances: Tool<any>[] = [
|
||||
new ExecuteCodeTool(this),
|
||||
new HighLevelOverviewTool(this),
|
||||
new PenpotApiInfoTool(this, this.apiDocs),
|
||||
new ExportShapeTool(this), // tool adapts to file system access internally
|
||||
];
|
||||
if (this.isFileSystemAccessEnabled()) {
|
||||
toolInstances.push(new ImportImageTool(this));
|
||||
}
|
||||
|
||||
for (const tool of toolInstances) {
|
||||
const toolName = tool.getToolName();
|
||||
this.tools.set(toolName, tool);
|
||||
|
||||
// Register each tool with McpServer
|
||||
this.logger.info(`Registering tool: ${toolName}`);
|
||||
this.server.registerTool(
|
||||
toolName,
|
||||
{
|
||||
description: tool.getToolDescription(),
|
||||
inputSchema: tool.getInputSchema(),
|
||||
},
|
||||
async (args) => {
|
||||
return tool.execute(args);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setupHttpEndpoints(): void {
|
||||
/**
|
||||
* Modern Streamable HTTP connection endpoint
|
||||
*/
|
||||
this.app.all("/mcp", async (req: any, res: any) => {
|
||||
const userToken = req.query.userToken as string | undefined;
|
||||
|
||||
await this.sessionContext.run({ userToken }, async () => {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && this.transports.streamable[sessionId]) {
|
||||
transport = this.transports.streamable[sessionId];
|
||||
} else {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id: string) => {
|
||||
this.transports.streamable[id] = transport;
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
delete this.transports.streamable[transport.sessionId];
|
||||
}
|
||||
};
|
||||
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy SSE connection endpoint
|
||||
*/
|
||||
this.app.get("/sse", async (req: any, res: any) => {
|
||||
const userToken = req.query.userToken as string | undefined;
|
||||
|
||||
await this.sessionContext.run({ userToken }, async () => {
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
this.transports.sse[transport.sessionId] = { transport, userToken };
|
||||
|
||||
res.on("close", () => {
|
||||
delete this.transports.sse[transport.sessionId];
|
||||
});
|
||||
|
||||
await this.server.connect(transport);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* SSE message POST endpoint (using previously established session)
|
||||
*/
|
||||
this.app.post("/messages", async (req: any, res: any) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const session = this.transports.sse[sessionId];
|
||||
|
||||
if (session) {
|
||||
await this.sessionContext.run({ userToken: session.userToken }, async () => {
|
||||
await session.transport.handlePostMessage(req, res, req.body);
|
||||
});
|
||||
} else {
|
||||
res.status(400).send("No transport found for sessionId");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const { default: express } = await import("express");
|
||||
this.app = express();
|
||||
this.app.use(express.json());
|
||||
|
||||
this.setupHttpEndpoints();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.app.listen(this.port, this.listenAddress, async () => {
|
||||
this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`);
|
||||
this.logger.info(`Remote mode: ${this.isRemoteMode()}`);
|
||||
this.logger.info(`Modern Streamable HTTP endpoint: http://${this.serverAddress}:${this.port}/mcp`);
|
||||
this.logger.info(`Legacy SSE endpoint: http://${this.serverAddress}:${this.port}/sse`);
|
||||
this.logger.info(`WebSocket server URL: ws://${this.serverAddress}:${this.webSocketPort}`);
|
||||
|
||||
// start the REPL server
|
||||
await this.replServer.start();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the MCP server and associated services.
|
||||
*
|
||||
* Gracefully shuts down the REPL server and other components.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.info("Stopping Penpot MCP Server...");
|
||||
await this.replServer.stop();
|
||||
this.logger.info("Penpot MCP Server stopped");
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import * as http from "http";
|
||||
import { PluginTask } from "./PluginTask";
|
||||
import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common";
|
||||
import { createLogger } from "./logger";
|
||||
import type { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
|
||||
interface ClientConnection {
|
||||
socket: WebSocket;
|
||||
userToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections to Penpot plugin instances and handles plugin tasks
|
||||
* over these connections.
|
||||
*/
|
||||
export class PluginBridge {
|
||||
private readonly logger = createLogger("PluginBridge");
|
||||
private readonly wsServer: WebSocketServer;
|
||||
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
|
||||
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
|
||||
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
|
||||
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(
|
||||
public readonly mcpServer: PenpotMcpServer,
|
||||
private port: number,
|
||||
private taskTimeoutSecs: number = 30
|
||||
) {
|
||||
this.wsServer = new WebSocketServer({ port: port });
|
||||
this.setupWebSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up WebSocket connection handlers for plugin communication.
|
||||
*
|
||||
* Manages client connections and provides bidirectional communication
|
||||
* channel between the MCP mcpServer and Penpot plugin instances.
|
||||
*/
|
||||
private setupWebSocketHandlers(): void {
|
||||
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
// extract userToken from query parameters
|
||||
const url = new URL(request.url!, `ws://${request.headers.host}`);
|
||||
const userToken = url.searchParams.get("userToken");
|
||||
|
||||
// require userToken if running in multi-user mode
|
||||
if (this.mcpServer.isMultiUserMode() && !userToken) {
|
||||
this.logger.warn("Connection attempt without userToken in multi-user mode - rejecting");
|
||||
ws.close(1008, "Missing userToken parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
this.logger.info("New WebSocket connection established (token provided)");
|
||||
} else {
|
||||
this.logger.info("New WebSocket connection established");
|
||||
}
|
||||
|
||||
// register the client connection with both indexes
|
||||
const connection: ClientConnection = { socket: ws, userToken };
|
||||
this.connectedClients.set(ws, connection);
|
||||
if (userToken) {
|
||||
// ensure only one connection per userToken
|
||||
if (this.clientsByToken.has(userToken)) {
|
||||
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
|
||||
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
|
||||
}
|
||||
|
||||
this.clientsByToken.set(userToken, connection);
|
||||
}
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
this.logger.debug("Received WebSocket message: %s", data.toString());
|
||||
try {
|
||||
const response: PluginTaskResponse<any> = JSON.parse(data.toString());
|
||||
this.handlePluginTaskResponse(response);
|
||||
} catch (error) {
|
||||
this.logger.error(error, "Failure while processing WebSocket message");
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.logger.info("WebSocket connection closed");
|
||||
const connection = this.connectedClients.get(ws);
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection?.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
this.logger.error(error, "WebSocket connection error");
|
||||
const connection = this.connectedClients.get(ws);
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection?.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.info("WebSocket mcpServer started on port %d", this.port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles responses from the plugin for completed tasks.
|
||||
*
|
||||
* Finds the pending task by ID and resolves or rejects its promise
|
||||
* based on the execution result.
|
||||
*
|
||||
* @param response - The plugin task response containing ID and result
|
||||
*/
|
||||
private handlePluginTaskResponse(response: PluginTaskResponse<any>): void {
|
||||
const task = this.pendingTasks.get(response.id);
|
||||
if (!task) {
|
||||
this.logger.info(`Received response for unknown task ID: ${response.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the timeout and remove the task from pending tasks
|
||||
const timeoutHandle = this.taskTimeouts.get(response.id);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.taskTimeouts.delete(response.id);
|
||||
}
|
||||
this.pendingTasks.delete(response.id);
|
||||
|
||||
// Resolve or reject the task's promise based on the result
|
||||
if (response.success) {
|
||||
task.resolveWithResult({ data: response.data });
|
||||
} else {
|
||||
const error = new Error(response.error || "Task execution failed (details not provided)");
|
||||
task.rejectWithError(error);
|
||||
}
|
||||
|
||||
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the client connection to use for executing a task.
|
||||
*
|
||||
* In single-user mode, returns the single connected client.
|
||||
* In multi-user mode, returns the client matching the session's userToken.
|
||||
*
|
||||
* @returns The client connection to use
|
||||
* @throws Error if no suitable connection is found or if configuration is invalid
|
||||
*/
|
||||
private getClientConnection(): ClientConnection {
|
||||
if (this.mcpServer.isMultiUserMode()) {
|
||||
const sessionContext = this.mcpServer.getSessionContext();
|
||||
if (!sessionContext?.userToken) {
|
||||
throw new Error("No userToken found in session context. Multi-user mode requires authentication.");
|
||||
}
|
||||
|
||||
const connection = this.clientsByToken.get(sessionContext.userToken);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
|
||||
);
|
||||
}
|
||||
|
||||
return connection;
|
||||
} else {
|
||||
// single-user mode: return the single connected client
|
||||
if (this.connectedClients.size === 0) {
|
||||
throw new Error(
|
||||
`No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
|
||||
);
|
||||
}
|
||||
if (this.connectedClients.size > 1) {
|
||||
throw new Error(
|
||||
`Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` +
|
||||
`Ask the user to ensure that only one instance is connected at a time.`
|
||||
);
|
||||
}
|
||||
|
||||
// return the first (and only) connection
|
||||
const connection = this.connectedClients.values().next().value;
|
||||
return <ClientConnection>connection;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a plugin task by sending it to connected clients.
|
||||
*
|
||||
* Registers the task for result correlation and returns a promise
|
||||
* that resolves when the plugin responds with the execution result.
|
||||
*
|
||||
* @param task - The plugin task to execute
|
||||
* @throws Error if no plugin instances are connected or available
|
||||
*/
|
||||
public async executePluginTask<TResult extends PluginTaskResult<any>>(
|
||||
task: PluginTask<any, TResult>
|
||||
): Promise<TResult> {
|
||||
// get the appropriate client connection based on mode
|
||||
const connection = this.getClientConnection();
|
||||
|
||||
// register the task for result correlation
|
||||
this.pendingTasks.set(task.id, task);
|
||||
|
||||
// send task to the selected client
|
||||
const requestMessage = JSON.stringify(task.toRequest());
|
||||
if (connection.socket.readyState !== 1) {
|
||||
// WebSocket is not open
|
||||
this.pendingTasks.delete(task.id);
|
||||
throw new Error(`Plugin instance is disconnected. Task could not be sent.`);
|
||||
}
|
||||
|
||||
connection.socket.send(requestMessage);
|
||||
|
||||
// Set up a timeout to reject the task if no response is received
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
const pendingTask = this.pendingTasks.get(task.id);
|
||||
if (pendingTask) {
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.taskTimeouts.delete(task.id);
|
||||
pendingTask.rejectWithError(
|
||||
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
|
||||
);
|
||||
}
|
||||
}, this.taskTimeoutSecs * 1000);
|
||||
|
||||
this.taskTimeouts.set(task.id, timeoutHandle);
|
||||
this.logger.info(`Sent task ${task.id} to connected client`);
|
||||
|
||||
return await task.getResultPromise();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Base class for plugin tasks that are sent over WebSocket.
|
||||
*
|
||||
* Each task defines a specific operation for the plugin to execute
|
||||
* along with strongly-typed parameters.
|
||||
*
|
||||
* @template TParams - The strongly-typed parameters for this task
|
||||
*/
|
||||
import { PluginTaskRequest, PluginTaskResult } from "@penpot/mcp-common";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/**
|
||||
* Base class for plugin tasks that are sent over WebSocket.
|
||||
*
|
||||
* Each task defines a specific operation for the plugin to execute
|
||||
* along with strongly-typed parameters and request/response correlation.
|
||||
*
|
||||
* @template TParams - The strongly-typed parameters for this task
|
||||
* @template TResult - The expected result type from task execution
|
||||
*/
|
||||
export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult<any> = PluginTaskResult<any>> {
|
||||
/**
|
||||
* Unique identifier for request/response correlation.
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/**
|
||||
* The name of the task to execute on the plugin side.
|
||||
*/
|
||||
public readonly task: string;
|
||||
|
||||
/**
|
||||
* The parameters for this task execution.
|
||||
*/
|
||||
public readonly params: TParams;
|
||||
|
||||
/**
|
||||
* Promise that resolves when the task execution completes.
|
||||
*/
|
||||
private readonly result: Promise<TResult>;
|
||||
|
||||
/**
|
||||
* Resolver function for the result promise.
|
||||
*/
|
||||
private resolveResult?: (result: TResult) => void;
|
||||
|
||||
/**
|
||||
* Rejector function for the result promise.
|
||||
*/
|
||||
private rejectResult?: (error: Error) => void;
|
||||
|
||||
/**
|
||||
* Creates a new plugin task instance.
|
||||
*
|
||||
* @param task - The name of the task to execute
|
||||
* @param params - The parameters for task execution
|
||||
*/
|
||||
constructor(task: string, params: TParams) {
|
||||
this.id = randomUUID();
|
||||
this.task = task;
|
||||
this.params = params;
|
||||
this.result = new Promise<TResult>((resolve, reject) => {
|
||||
this.resolveResult = resolve;
|
||||
this.rejectResult = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the result promise for this task.
|
||||
*
|
||||
* @returns Promise that resolves when the task execution completes
|
||||
*/
|
||||
getResultPromise(): Promise<TResult> {
|
||||
if (!this.result) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
return this.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the task with the given result.
|
||||
*
|
||||
* This method should be called when a task response is received
|
||||
* from the plugin with matching ID.
|
||||
*
|
||||
* @param result - The task execution result
|
||||
*/
|
||||
resolveWithResult(result: TResult): void {
|
||||
if (!this.resolveResult) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
this.resolveResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects the task with the given error.
|
||||
*
|
||||
* This method should be called when task execution fails
|
||||
* or times out.
|
||||
*
|
||||
* @param error - The error that occurred during task execution
|
||||
*/
|
||||
rejectWithError(error: Error): void {
|
||||
if (!this.rejectResult) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
this.rejectResult(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the task to a request message for WebSocket transmission.
|
||||
*
|
||||
* @returns The request message containing ID, task name, and parameters
|
||||
*/
|
||||
toRequest(): PluginTaskRequest {
|
||||
return {
|
||||
id: this.id,
|
||||
task: this.task,
|
||||
params: this.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { PluginBridge } from "./PluginBridge";
|
||||
import { ExecuteCodePluginTask } from "./tasks/ExecuteCodePluginTask";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
/**
|
||||
* Web-based REPL server for executing code through the PluginBridge.
|
||||
*
|
||||
* Provides a REPL-style HTML interface that allows users to input
|
||||
* JavaScript code and execute it via ExecuteCodePluginTask instances.
|
||||
* The interface maintains command history, displays logs in <pre> tags,
|
||||
* and shows results in visually separated blocks.
|
||||
*/
|
||||
export class ReplServer {
|
||||
private readonly logger = createLogger("ReplServer");
|
||||
private readonly app: express.Application;
|
||||
private readonly port: number;
|
||||
private server: any;
|
||||
|
||||
constructor(
|
||||
private readonly pluginBridge: PluginBridge,
|
||||
port: number = 4403
|
||||
) {
|
||||
this.port = port;
|
||||
this.app = express();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up Express middleware for request parsing and static content.
|
||||
*/
|
||||
private setupMiddleware(): void {
|
||||
this.app.use(express.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up HTTP routes for the REPL interface and API endpoints.
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// serve the main REPL interface
|
||||
this.app.get("/", (req, res) => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const htmlPath = path.join(__dirname, "static", "repl.html");
|
||||
res.sendFile(htmlPath);
|
||||
});
|
||||
|
||||
// API endpoint for executing code
|
||||
this.app.post("/execute", async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "Code parameter is required and must be a string",
|
||||
});
|
||||
}
|
||||
|
||||
const task = new ExecuteCodePluginTask({ code });
|
||||
const result = await this.pluginBridge.executePluginTask(task);
|
||||
|
||||
// extract the result member from ExecuteCodeTaskResultData
|
||||
const executeResult = result.data?.result;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: executeResult,
|
||||
log: result.data?.log || "",
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(error, "Failed to execute code in REPL");
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the REPL web server.
|
||||
*
|
||||
* Begins listening on the configured port and logs server startup information.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = this.app.listen(this.port, () => {
|
||||
this.logger.info(`REPL server started on port ${this.port}`);
|
||||
this.logger.info(
|
||||
`REPL interface URL: http://${this.pluginBridge.mcpServer.serverAddress}:${this.port}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the REPL web server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(() => {
|
||||
this.logger.info("REPL server stopped");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import "reflect-metadata";
|
||||
import { TextResponse, ToolResponse } from "./ToolResponse";
|
||||
import type { PenpotMcpServer, SessionContext } from "./PenpotMcpServer";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
/**
|
||||
* An empty arguments class for tools that do not require any parameters.
|
||||
*/
|
||||
export class EmptyToolArgs {
|
||||
static schema = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for type-safe tools with automatic schema generation and validation.
|
||||
*
|
||||
* This class provides type safety through automatic validation and strongly-typed
|
||||
* protected methods. All tools should extend this class.
|
||||
*
|
||||
* @template TArgs - The strongly-typed arguments class for this tool
|
||||
*/
|
||||
export abstract class Tool<TArgs extends object> {
|
||||
private readonly logger = createLogger("Tool");
|
||||
|
||||
protected constructor(
|
||||
protected mcpServer: PenpotMcpServer,
|
||||
private inputSchema: z.ZodRawShape
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes the tool with automatic validation and type safety.
|
||||
*
|
||||
* This method handles the unknown args from the MCP protocol,
|
||||
* delegating to the type-safe implementation.
|
||||
*/
|
||||
async execute(args: unknown): Promise<ToolResponse> {
|
||||
try {
|
||||
let argsInstance: TArgs = args as TArgs;
|
||||
this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance));
|
||||
|
||||
// execute the actual tool logic
|
||||
let result = await this.executeCore(argsInstance);
|
||||
|
||||
this.logger.info("Tool execution completed: %s", this.getToolName());
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return new TextResponse(`Tool execution failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats tool arguments for readable logging.
|
||||
*
|
||||
* Multi-line strings are preserved with proper indentation.
|
||||
*/
|
||||
protected formatArgs(args: TArgs): string {
|
||||
const formatted: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (typeof value === "string" && value.includes("\n")) {
|
||||
// multi-line string - preserve formatting with indentation
|
||||
const indentedValue = value
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : " " + line))
|
||||
.join("\n");
|
||||
formatted.push(` ${key}: ${indentedValue}`);
|
||||
} else if (typeof value === "string") {
|
||||
// single-line string
|
||||
formatted.push(` ${key}: "${value}"`);
|
||||
} else if (value === null || value === undefined) {
|
||||
formatted.push(` ${key}: ${value}`);
|
||||
} else {
|
||||
// other types (numbers, booleans, objects, arrays)
|
||||
const stringified = JSON.stringify(value, null, 2);
|
||||
if (stringified.includes("\n")) {
|
||||
// multi-line JSON - indent it
|
||||
const indented = stringified
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : " " + line))
|
||||
.join("\n");
|
||||
formatted.push(` ${key}: ${indented}`);
|
||||
} else {
|
||||
formatted.push(` ${key}: ${stringified}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatted.length > 0 ? "\n" + formatted.join("\n") : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current session context.
|
||||
*
|
||||
* @returns The session context for the current request, or undefined if not in a request context
|
||||
*/
|
||||
protected getSessionContext(): SessionContext | undefined {
|
||||
return this.mcpServer.getSessionContext();
|
||||
}
|
||||
|
||||
public getInputSchema() {
|
||||
return this.inputSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tool's unique name.
|
||||
*/
|
||||
public abstract getToolName(): string;
|
||||
|
||||
/**
|
||||
* Returns the tool's description.
|
||||
*/
|
||||
public abstract getToolDescription(): string;
|
||||
|
||||
/**
|
||||
* Executes the tool's core logic.
|
||||
*
|
||||
* @param args - The (typed) tool arguments
|
||||
*/
|
||||
protected abstract executeCore(args: TArgs): Promise<ToolResponse>;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
type CallToolContent = CallToolResult["content"][number];
|
||||
type TextItem = Extract<CallToolContent, { type: "text" }>;
|
||||
type ImageItem = Extract<CallToolContent, { type: "image" }>;
|
||||
|
||||
export class TextContent implements TextItem {
|
||||
[x: string]: unknown;
|
||||
readonly type = "text" as const;
|
||||
constructor(public text: string) {}
|
||||
|
||||
/**
|
||||
* @param data - Text data as string or as object (from JSON representation where indices are mapped to character codes)
|
||||
*/
|
||||
public static textData(data: string | object): string {
|
||||
if (typeof data === "object") {
|
||||
// convert object containing character codes (as obtained from JSON conversion of string) back to string
|
||||
return String.fromCharCode(...(Object.values(data) as number[]));
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageContent implements ImageItem {
|
||||
[x: string]: unknown;
|
||||
readonly type = "image" as const;
|
||||
|
||||
/**
|
||||
* @param data - Base64-encoded image data
|
||||
* @param mimeType - MIME type of the image (e.g., "image/png")
|
||||
*/
|
||||
constructor(
|
||||
public data: string,
|
||||
public mimeType: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Utility function for ensuring a consistent Uint8Array representation of byte data.
|
||||
* Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array
|
||||
* from the plugin).
|
||||
*
|
||||
* @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array)
|
||||
* @return data as Uint8Array
|
||||
*/
|
||||
public static byteData(data: Uint8Array | object): Uint8Array {
|
||||
if (typeof data === "object") {
|
||||
// convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
|
||||
return new Uint8Array(Object.values(data) as number[]);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PNGImageContent extends ImageContent {
|
||||
/**
|
||||
* @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array)
|
||||
*/
|
||||
constructor(data: Uint8Array | object) {
|
||||
let array = ImageContent.byteData(data);
|
||||
super(Buffer.from(array).toString("base64"), "image/png");
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolResponse implements CallToolResult {
|
||||
[x: string]: unknown;
|
||||
content: CallToolContent[]; // <- IMPORTANT: protocol’s union
|
||||
constructor(content: CallToolContent[]) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextResponse extends ToolResponse {
|
||||
constructor(text: string) {
|
||||
super([new TextContent(text)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TextResponse from text data given as string or as object (from JSON representation where indices are mapped to
|
||||
* character codes).
|
||||
*
|
||||
* @param data - Text data as string or as object (from JSON representation where indices are mapped to character codes)
|
||||
*/
|
||||
public static fromData(data: string | object): TextResponse {
|
||||
return new TextResponse(TextContent.textData(data));
|
||||
}
|
||||
}
|
||||
|
||||
export class PNGResponse extends ToolResponse {
|
||||
/**
|
||||
* @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array)
|
||||
*/
|
||||
constructor(data: Uint8Array | object) {
|
||||
super([new PNGImageContent(data)]);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
import { createLogger, logFilePath } from "./logger";
|
||||
|
||||
/**
|
||||
* Entry point for Penpot MCP Server
|
||||
*
|
||||
* Creates and starts the MCP server instance, handling any startup errors
|
||||
* gracefully and ensuring proper process termination.
|
||||
*
|
||||
* Configuration via environment variables (see README).
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const logger = createLogger("main");
|
||||
|
||||
// log the file path early so it appears before any potential errors
|
||||
logger.info(`Logging to file: ${logFilePath}`);
|
||||
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
let multiUser = false; // default to single-user mode
|
||||
|
||||
// parse command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--multi-user") {
|
||||
multiUser = true;
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
logger.info("Usage: node dist/index.js [options]");
|
||||
logger.info("Options:");
|
||||
logger.info(" --multi-user Enable multi-user mode (default: single-user)");
|
||||
logger.info(" --help, -h Show this help message");
|
||||
logger.info("");
|
||||
logger.info("Note that configuration is mostly handled through environment variables.");
|
||||
logger.info("Refer to the README for more information.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const server = new PenpotMcpServer(multiUser);
|
||||
await server.start();
|
||||
|
||||
// keep the process alive
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Received SIGINT, shutting down gracefully...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("Received SIGTERM, shutting down gracefully...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to start MCP server");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server if this file is run directly
|
||||
if (import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("index.js")) {
|
||||
main().catch((error) => {
|
||||
createLogger("main").error(error, "Unhandled error in main");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
/**
|
||||
* Configuration for log file location and level.
|
||||
*/
|
||||
const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR || "logs";
|
||||
const LOG_LEVEL = process.env.PENPOT_MCP_LOG_LEVEL || "info";
|
||||
|
||||
/**
|
||||
* Generates a timestamped log file name.
|
||||
*
|
||||
* @returns Log file name
|
||||
*/
|
||||
function generateLogFileName(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||
return `penpot-mcp-${year}${month}${day}-${hours}${minutes}${seconds}.log`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the log file being written.
|
||||
*/
|
||||
export const logFilePath = resolve(join(LOG_DIR, generateLogFileName()));
|
||||
|
||||
/**
|
||||
* Logger instance configured for both console and file output with metadata.
|
||||
*
|
||||
* Both console and file output use pretty formatting for human readability.
|
||||
* Console output includes colors, while file output is plain text.
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: LOG_LEVEL,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
transport: {
|
||||
targets: [
|
||||
{
|
||||
// console transport with pretty formatting
|
||||
target: "pino-pretty",
|
||||
level: LOG_LEVEL,
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
||||
ignore: "pid,hostname",
|
||||
messageFormat: "{msg}",
|
||||
levelFirst: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// file transport with pretty formatting (same as console)
|
||||
target: "pino-pretty",
|
||||
level: LOG_LEVEL,
|
||||
options: {
|
||||
destination: logFilePath,
|
||||
colorize: false,
|
||||
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
||||
ignore: "pid,hostname",
|
||||
messageFormat: "{msg}",
|
||||
levelFirst: true,
|
||||
mkdir: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a child logger with the specified name/origin.
|
||||
*
|
||||
* @param name - The name/origin identifier for the logger
|
||||
* @returns Child logger instance with the specified name
|
||||
*/
|
||||
export function createLogger(name: string) {
|
||||
return logger.child({ name });
|
||||
}
|
||||
@@ -1,554 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Penpot API REPL</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.repl-container {
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.repl-entry {
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.repl-entry:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: #007acc;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
font-size: 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
resize: vertical;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.code-input:read-only {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #e9ecef;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.code-input:focus {
|
||||
outline: none;
|
||||
border-color: #007acc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.1);
|
||||
}
|
||||
|
||||
.execute-btn {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.execute-btn:hover {
|
||||
background-color: #005a9e;
|
||||
}
|
||||
|
||||
.execute-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.output-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.output-label {
|
||||
color: #28a745;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.result-output {
|
||||
background-color: #f0fff4;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
font-size: 14px;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.error-output {
|
||||
background-color: #fff5f5;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
font-size: 14px;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.loading-output {
|
||||
background-color: #fffbf0;
|
||||
border: 1px solid #ffeeba;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.controls-hint {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-number {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.history-indicator {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: #856404;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="repl-container" id="repl-container">
|
||||
<!-- REPL entries will be dynamically added here -->
|
||||
</div>
|
||||
|
||||
<div class="controls-hint">Ctrl+Enter to execute • Arrow up/down for command history</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
let isExecuting = false;
|
||||
let entryCounter = 1;
|
||||
let commandHistory = []; // full history of executed commands
|
||||
let historyIndex = 0; // current position in history
|
||||
let isBrowsingHistory = false; // whether we are currently browsing history
|
||||
let tempInput = ""; // temporary storage for current input when browsing history
|
||||
|
||||
// create the initial input entry
|
||||
createNewEntry();
|
||||
|
||||
function createNewEntry() {
|
||||
const entryId = `entry-${entryCounter}`;
|
||||
const isFirstEntry = entryCounter === 1;
|
||||
const placeholder = isFirstEntry
|
||||
? `// Enter your JavaScript code here...
|
||||
console.log('Hello from Penpot!');
|
||||
return 'This will be the result';`
|
||||
: "";
|
||||
const entryHtml = `
|
||||
<div class="repl-entry" id="${entryId}">
|
||||
<div class="entry-number">In [${entryCounter}]:</div>
|
||||
<div class="input-section">
|
||||
<textarea class="code-input" id="code-input-${entryCounter}"
|
||||
placeholder="${placeholder}"></textarea>
|
||||
<button class="execute-btn" id="execute-btn-${entryCounter}">Execute Code</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#repl-container").append(entryHtml);
|
||||
|
||||
// bind events for this entry
|
||||
bindEntryEvents(entryCounter);
|
||||
|
||||
// focus on the new input without scrolling
|
||||
const $input = $(`#code-input-${entryCounter}`);
|
||||
$input[0].focus({ preventScroll: true });
|
||||
|
||||
// auto-resize textarea on input
|
||||
$input.on("input", function () {
|
||||
autoResizeTextarea(this);
|
||||
});
|
||||
|
||||
entryCounter++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a textarea to fit its content, with a minimum height.
|
||||
* Adds border height since scrollHeight excludes borders but box-sizing: border-box includes them.
|
||||
*/
|
||||
function autoResizeTextarea(textarea) {
|
||||
textarea.style.height = "auto";
|
||||
// add 2px for top and bottom border (1px each)
|
||||
textarea.style.height = Math.max(80, textarea.scrollHeight + 2) + "px";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cursor is at the beginning of a textarea (position 0 with no selection).
|
||||
*/
|
||||
function isCursorAtBeginning(textarea) {
|
||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cursor is at the end of a textarea (position at text length with no selection).
|
||||
*/
|
||||
function isCursorAtEnd(textarea) {
|
||||
const len = textarea.value.length;
|
||||
return textarea.selectionStart === len && textarea.selectionEnd === len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates through command history for the given entry's textarea.
|
||||
* @param direction -1 for previous (up), +1 for next (down)
|
||||
* @param entryNum the entry number
|
||||
*/
|
||||
function navigateHistory(direction, entryNum) {
|
||||
const $codeInput = $(`#code-input-${entryNum}`);
|
||||
const textarea = $codeInput[0];
|
||||
|
||||
if (commandHistory.length === 0) return;
|
||||
|
||||
if (direction === -1) {
|
||||
// going back in history (arrow up)
|
||||
if (!isBrowsingHistory) {
|
||||
// starting to browse history: save current input
|
||||
tempInput = $codeInput.val();
|
||||
isBrowsingHistory = true;
|
||||
historyIndex = commandHistory.length - 1;
|
||||
} else if (historyIndex > 0) {
|
||||
// go further back in history
|
||||
historyIndex--;
|
||||
} else {
|
||||
// already at oldest entry, do nothing
|
||||
return;
|
||||
}
|
||||
$codeInput.val(commandHistory[historyIndex]);
|
||||
autoResizeTextarea(textarea);
|
||||
// keep cursor at beginning for continued history navigation
|
||||
textarea.setSelectionRange(0, 0);
|
||||
// show history position (1 = most recent)
|
||||
const position = commandHistory.length - historyIndex;
|
||||
showHistoryIndicator(entryNum, position, commandHistory.length);
|
||||
} else {
|
||||
// going forward in history (arrow down)
|
||||
if (!isBrowsingHistory) {
|
||||
// not browsing history, do nothing
|
||||
return;
|
||||
} else if (historyIndex >= commandHistory.length - 1) {
|
||||
// at most recent entry, return to original input
|
||||
isBrowsingHistory = false;
|
||||
$codeInput.val(tempInput);
|
||||
autoResizeTextarea(textarea);
|
||||
// cursor at beginning (same as when we entered history)
|
||||
textarea.setSelectionRange(0, 0);
|
||||
hideHistoryIndicator();
|
||||
} else {
|
||||
// go forward in history
|
||||
historyIndex++;
|
||||
$codeInput.val(commandHistory[historyIndex]);
|
||||
autoResizeTextarea(textarea);
|
||||
// keep cursor at beginning
|
||||
textarea.setSelectionRange(0, 0);
|
||||
// update history position indicator
|
||||
const position = commandHistory.length - historyIndex;
|
||||
showHistoryIndicator(entryNum, position, commandHistory.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exits history browsing mode, keeping current content in the input.
|
||||
* Moves cursor to end of input.
|
||||
* @param entryNum the entry number (optional, cursor not moved if not provided)
|
||||
*/
|
||||
function exitHistoryBrowsing(entryNum) {
|
||||
if (isBrowsingHistory) {
|
||||
isBrowsingHistory = false;
|
||||
hideHistoryIndicator();
|
||||
if (entryNum !== undefined) {
|
||||
const textarea = $(`#code-input-${entryNum}`)[0];
|
||||
const len = textarea.value.length;
|
||||
textarea.setSelectionRange(len, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the repl container to show the output section of the given entry.
|
||||
*/
|
||||
function scrollToOutput($entry) {
|
||||
const $container = $("#repl-container");
|
||||
const $outputSection = $entry.find(".output-section");
|
||||
if ($outputSection.length) {
|
||||
const containerTop = $container.offset().top;
|
||||
const outputTop = $outputSection.offset().top;
|
||||
const scrollTop = $container.scrollTop();
|
||||
$container.animate(
|
||||
{
|
||||
scrollTop: scrollTop + (outputTop - containerTop),
|
||||
},
|
||||
300
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or updates the history indicator for the current entry.
|
||||
* @param entryNum the entry number
|
||||
* @param position 1-based position from most recent (1 = most recent)
|
||||
* @param total total number of history items
|
||||
*/
|
||||
function showHistoryIndicator(entryNum, position, total) {
|
||||
const $entry = $(`#entry-${entryNum}`);
|
||||
let $indicator = $entry.find(".history-indicator");
|
||||
|
||||
if ($indicator.length === 0) {
|
||||
$entry.find(".input-section").before('<div class="history-indicator"></div>');
|
||||
$indicator = $entry.find(".history-indicator");
|
||||
}
|
||||
|
||||
$indicator.text(`History item ${position}/${total}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the history indicator.
|
||||
*/
|
||||
function hideHistoryIndicator() {
|
||||
$(".history-indicator").remove();
|
||||
}
|
||||
|
||||
function bindEntryEvents(entryNum) {
|
||||
const $executeBtn = $(`#execute-btn-${entryNum}`);
|
||||
const $codeInput = $(`#code-input-${entryNum}`);
|
||||
|
||||
// bind execute button click
|
||||
$executeBtn.on("click", () => executeCode(entryNum));
|
||||
|
||||
// bind keyboard shortcuts
|
||||
$codeInput.on("keydown", function (e) {
|
||||
// Ctrl+Enter to execute
|
||||
if (e.ctrlKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
exitHistoryBrowsing(entryNum);
|
||||
executeCode(entryNum);
|
||||
return;
|
||||
}
|
||||
|
||||
// arrow up at beginning of input (or while browsing history): navigate to previous history entry
|
||||
if (e.key === "ArrowUp" && (isBrowsingHistory || isCursorAtBeginning(this))) {
|
||||
e.preventDefault();
|
||||
navigateHistory(-1, entryNum);
|
||||
return;
|
||||
}
|
||||
|
||||
// arrow down at end of input (or while browsing history): navigate to next history entry
|
||||
if (e.key === "ArrowDown" && (isBrowsingHistory || isCursorAtEnd(this))) {
|
||||
e.preventDefault();
|
||||
navigateHistory(+1, entryNum);
|
||||
return;
|
||||
}
|
||||
|
||||
// any key except pure modifier keys exits history browsing
|
||||
if (isBrowsingHistory) {
|
||||
const isModifierOnly = ["Shift", "Control", "Alt", "Meta"].includes(e.key);
|
||||
if (!isModifierOnly) {
|
||||
exitHistoryBrowsing();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setExecuting(entryNum, executing) {
|
||||
isExecuting = executing;
|
||||
$(`#execute-btn-${entryNum}`).prop("disabled", executing);
|
||||
$(`#execute-btn-${entryNum}`).text(executing ? "Executing..." : "Execute Code");
|
||||
}
|
||||
|
||||
function displayResult(entryNum, data, isError = false) {
|
||||
const $entry = $(`#entry-${entryNum}`);
|
||||
|
||||
// remove any existing output
|
||||
$entry.find(".output-section").remove();
|
||||
|
||||
// create output section
|
||||
const outputHtml = `
|
||||
<div class="output-section">
|
||||
<div class="output-label">Out [${entryNum}]:</div>
|
||||
<div class="output-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$entry.append(outputHtml);
|
||||
const $outputContent = $entry.find(".output-content");
|
||||
|
||||
if (isError) {
|
||||
const errorHtml = `<div class="error-output">Error: ${data.error}</div>`;
|
||||
$outputContent.html(errorHtml);
|
||||
} else {
|
||||
let outputElements = "";
|
||||
|
||||
// add log output if present
|
||||
if (data.log && data.log.trim()) {
|
||||
outputElements += `<div class="log-output">${escapeHtml(data.log)}</div>`;
|
||||
}
|
||||
|
||||
// add result output
|
||||
let resultText;
|
||||
if (data.result !== undefined) {
|
||||
resultText = JSON.stringify(data.result, null, 2);
|
||||
} else {
|
||||
resultText = "(no return value)";
|
||||
}
|
||||
|
||||
outputElements += `<div class="result-output">${escapeHtml(resultText)}</div>`;
|
||||
|
||||
$outputContent.html(outputElements);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function executeCode(entryNum) {
|
||||
if (isExecuting) return;
|
||||
|
||||
const $codeInput = $(`#code-input-${entryNum}`);
|
||||
const code = $codeInput.val().trim();
|
||||
|
||||
if (!code) {
|
||||
displayResult(entryNum, { error: "Please enter some code to execute" }, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setExecuting(entryNum, true);
|
||||
|
||||
// show loading state
|
||||
const $entry = $(`#entry-${entryNum}`);
|
||||
$entry.find(".output-section").remove();
|
||||
|
||||
const loadingHtml = `
|
||||
<div class="output-section">
|
||||
<div class="output-label">Out [${entryNum}]:</div>
|
||||
<div class="loading-output">Executing code...</div>
|
||||
</div>
|
||||
`;
|
||||
$entry.append(loadingHtml);
|
||||
|
||||
$.ajax({
|
||||
url: "/execute",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({ code: code }),
|
||||
success: function (data) {
|
||||
displayResult(entryNum, data, false);
|
||||
|
||||
// make the textarea read-only and remove the execute button
|
||||
$codeInput.prop("readonly", true);
|
||||
$(`#execute-btn-${entryNum}`).remove();
|
||||
|
||||
// store the code in history
|
||||
commandHistory.push(code);
|
||||
isBrowsingHistory = false; // reset history navigation
|
||||
tempInput = ""; // clear temporary input
|
||||
|
||||
// create a new entry for the next input
|
||||
createNewEntry();
|
||||
|
||||
// scroll to the output section of the executed entry
|
||||
scrollToOutput($entry);
|
||||
},
|
||||
error: function (xhr) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
errorData = { error: "Network error or invalid response" };
|
||||
}
|
||||
displayResult(entryNum, errorData, true);
|
||||
|
||||
// scroll to the error output
|
||||
scrollToOutput($entry);
|
||||
},
|
||||
complete: function () {
|
||||
setExecuting(entryNum, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
import { PluginTask } from "../PluginTask";
|
||||
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData, PluginTaskResult } from "@penpot/mcp-common";
|
||||
|
||||
/**
|
||||
* Task for executing JavaScript code in the plugin context.
|
||||
*
|
||||
* This task instructs the plugin to execute arbitrary JavaScript code
|
||||
* and return the result of execution.
|
||||
*/
|
||||
export class ExecuteCodePluginTask extends PluginTask<
|
||||
ExecuteCodeTaskParams,
|
||||
PluginTaskResult<ExecuteCodeTaskResultData<any>>
|
||||
> {
|
||||
/**
|
||||
* Creates a new execute code task.
|
||||
*
|
||||
* @param params - The parameters containing the code to execute
|
||||
*/
|
||||
constructor(params: ExecuteCodeTaskParams) {
|
||||
super("executeCode", params);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "../Tool";
|
||||
import type { ToolResponse } from "../ToolResponse";
|
||||
import { TextResponse } from "../ToolResponse";
|
||||
import "reflect-metadata";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
|
||||
import { ExecuteCodeTaskParams } from "@penpot/mcp-common";
|
||||
|
||||
/**
|
||||
* Arguments class for ExecuteCodeTool
|
||||
*/
|
||||
export class ExecuteCodeArgs {
|
||||
static schema = {
|
||||
code: z
|
||||
.string()
|
||||
.min(1, "Code cannot be empty")
|
||||
.describe("The JavaScript code to execute in the plugin context."),
|
||||
};
|
||||
|
||||
/**
|
||||
* The JavaScript code to execute in the plugin context.
|
||||
*/
|
||||
code!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for executing JavaScript code in the Penpot plugin context
|
||||
*/
|
||||
export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
|
||||
/**
|
||||
* Creates a new ExecuteCode tool instance.
|
||||
*
|
||||
* @param mcpServer - The MCP server instance
|
||||
*/
|
||||
constructor(mcpServer: PenpotMcpServer) {
|
||||
super(mcpServer, ExecuteCodeArgs.schema);
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "execute_code";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
return (
|
||||
"Executes JavaScript code in the Penpot plugin context.\n" +
|
||||
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
|
||||
"which Penpot API functionality is necessary and how to use it.\n" +
|
||||
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
|
||||
"and `storage`.\n" +
|
||||
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
|
||||
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
|
||||
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
|
||||
"you can also store functions and thus build up a library).\n" +
|
||||
"Think of the code being executed as the body of a function: " +
|
||||
"The tool call returns whatever you return in the applicable `return` statement, if any.\n" +
|
||||
"If an exception occurs, the exception's message will be returned to you.\n" +
|
||||
"Any output that you generate via the `console` object will be returned to you separately; so you may use it" +
|
||||
"to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " +
|
||||
"VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" +
|
||||
"VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " +
|
||||
"handling different cases (in particular error cases) and that applies logging."
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeCore(args: ExecuteCodeArgs): Promise<ToolResponse> {
|
||||
const taskParams: ExecuteCodeTaskParams = { code: args.code };
|
||||
const task = new ExecuteCodePluginTask(taskParams);
|
||||
const result = await this.mcpServer.pluginBridge.executePluginTask(task);
|
||||
|
||||
if (result.data !== undefined) {
|
||||
return new TextResponse(JSON.stringify(result.data, null, 2));
|
||||
} else {
|
||||
return new TextResponse("Code executed successfully with no return value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "../Tool";
|
||||
import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse";
|
||||
import "reflect-metadata";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
|
||||
import { FileUtils } from "../utils/FileUtils";
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* Arguments class for ExportShapeTool
|
||||
*/
|
||||
export class ExportShapeArgs {
|
||||
static schema = {
|
||||
shapeId: z
|
||||
.string()
|
||||
.min(1, "shapeId cannot be empty")
|
||||
.describe(
|
||||
"Identifier of the shape to export. Use the special identifier 'selection' to " +
|
||||
"export the first shape currently selected by the user."
|
||||
),
|
||||
format: z.enum(["svg", "png"]).default("png").describe("The output format, either 'png' (default) or 'svg'."),
|
||||
mode: z
|
||||
.enum(["shape", "fill"])
|
||||
.default("shape")
|
||||
.describe(
|
||||
"The export mode: either 'shape' (full shape as it appears in the design, including descendants; the default) or " +
|
||||
"'fill' (export the raw image that is used as a fill for the shape; PNG format only)"
|
||||
),
|
||||
filePath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional file path to save the exported image to. If not provided, " +
|
||||
"the image data is returned directly for you to see."
|
||||
),
|
||||
};
|
||||
|
||||
shapeId!: string;
|
||||
|
||||
format: "svg" | "png" = "png";
|
||||
|
||||
mode: "shape" | "fill" = "shape";
|
||||
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for executing JavaScript code in the Penpot plugin context
|
||||
*/
|
||||
export class ExportShapeTool extends Tool<ExportShapeArgs> {
|
||||
/**
|
||||
* Creates a new ExecuteCode tool instance.
|
||||
*
|
||||
* @param mcpServer - The MCP server instance
|
||||
*/
|
||||
constructor(mcpServer: PenpotMcpServer) {
|
||||
let schema: any = ExportShapeArgs.schema;
|
||||
if (!mcpServer.isFileSystemAccessEnabled()) {
|
||||
// remove filePath key from schema
|
||||
schema = { ...schema };
|
||||
delete schema.filePath;
|
||||
}
|
||||
super(mcpServer, schema);
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "export_shape";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
let description =
|
||||
"Exports a shape (or a shape's image fill) from the Penpot design to a PNG or SVG image, " +
|
||||
"such that you can get an impression of what it looks like. ";
|
||||
if (this.mcpServer.isFileSystemAccessEnabled()) {
|
||||
description += "\nAlternatively, you can save it to a file.";
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
protected async executeCore(args: ExportShapeArgs): Promise<ToolResponse> {
|
||||
// check arguments
|
||||
if (args.filePath) {
|
||||
FileUtils.checkPathIsAbsolute(args.filePath);
|
||||
}
|
||||
|
||||
// create code for exporting the shape
|
||||
let shapeCode: string;
|
||||
if (args.shapeId === "selection") {
|
||||
shapeCode = `penpot.selection[0]`;
|
||||
} else {
|
||||
shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`;
|
||||
}
|
||||
const asSvg = args.format === "svg";
|
||||
const code = `return penpotUtils.exportImage(${shapeCode}, "${args.mode}", ${asSvg});`;
|
||||
|
||||
// execute the code and obtain the image data
|
||||
const task = new ExecuteCodePluginTask({ code: code });
|
||||
const result = await this.mcpServer.pluginBridge.executePluginTask(task);
|
||||
const imageData = result.data!.result;
|
||||
|
||||
// handle output and return response
|
||||
if (!args.filePath) {
|
||||
// return image data directly (for the LLM to "see" it)
|
||||
if (args.format === "png") {
|
||||
return new PNGResponse(await this.toPngImageBytes(imageData));
|
||||
} else {
|
||||
return TextResponse.fromData(imageData);
|
||||
}
|
||||
} else {
|
||||
// save to file requested: make sure file system access is enabled
|
||||
if (!this.mcpServer.isFileSystemAccessEnabled()) {
|
||||
throw new Error("File system access is not enabled on the MCP server!");
|
||||
}
|
||||
// save to file
|
||||
if (args.format === "png") {
|
||||
FileUtils.writeBinaryFile(args.filePath, await this.toPngImageBytes(imageData));
|
||||
} else {
|
||||
FileUtils.writeTextFile(args.filePath, TextContent.textData(imageData));
|
||||
}
|
||||
return new TextResponse(`The shape has been exported to ${args.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts image data to PNG format if necessary.
|
||||
*
|
||||
* @param data - The original image data as Uint8Array or as object (from JSON conversion of Uint8Array)
|
||||
* @return The image data as PNG bytes
|
||||
*/
|
||||
private async toPngImageBytes(data: Uint8Array | object): Promise<Uint8Array> {
|
||||
const originalBytes = ImageContent.byteData(data);
|
||||
|
||||
// use sharp to detect format and convert to PNG if necessary
|
||||
const image = sharp(originalBytes);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// if already PNG, return as-is to avoid unnecessary re-encoding
|
||||
if (metadata.format === "png") {
|
||||
return originalBytes;
|
||||
}
|
||||
|
||||
// convert to PNG
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
return new Uint8Array(pngBuffer);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { EmptyToolArgs, Tool } from "../Tool";
|
||||
import "reflect-metadata";
|
||||
import type { ToolResponse } from "../ToolResponse";
|
||||
import { TextResponse } from "../ToolResponse";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
|
||||
export class HighLevelOverviewTool extends Tool<EmptyToolArgs> {
|
||||
constructor(mcpServer: PenpotMcpServer) {
|
||||
super(mcpServer, EmptyToolArgs.schema);
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "high_level_overview";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
return (
|
||||
"Returns basic high-level instructions on the usage of Penpot-related tools and the Penpot API. " +
|
||||
"If you have already read the 'Penpot High-Level Overview', you must not call this tool."
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeCore(args: EmptyToolArgs): Promise<ToolResponse> {
|
||||
return new TextResponse(this.mcpServer.getInitialInstructions());
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "../Tool";
|
||||
import { TextResponse, ToolResponse } from "../ToolResponse";
|
||||
import "reflect-metadata";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
|
||||
import { FileUtils } from "../utils/FileUtils";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Arguments class for ImportImageTool
|
||||
*/
|
||||
export class ImportImageArgs {
|
||||
static schema = {
|
||||
filePath: z.string().min(1, "filePath cannot be empty").describe("Absolute path to the image file to import."),
|
||||
x: z.number().optional().describe("Optional X coordinate for the rectangle's position."),
|
||||
y: z.number().optional().describe("Optional Y coordinate for the rectangle's position."),
|
||||
width: z
|
||||
.number()
|
||||
.positive("width must be positive")
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional width for the rectangle. If only width is provided, height is calculated to maintain aspect ratio."
|
||||
),
|
||||
height: z
|
||||
.number()
|
||||
.positive("height must be positive")
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional height for the rectangle. If only height is provided, width is calculated to maintain aspect ratio."
|
||||
),
|
||||
};
|
||||
|
||||
filePath!: string;
|
||||
|
||||
x?: number;
|
||||
|
||||
y?: number;
|
||||
|
||||
width?: number;
|
||||
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for importing a raster image from the local file system into Penpot
|
||||
*/
|
||||
export class ImportImageTool extends Tool<ImportImageArgs> {
|
||||
/**
|
||||
* Maps file extensions to MIME types.
|
||||
*/
|
||||
protected static readonly MIME_TYPES: { [key: string]: string } = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new ImportImage tool instance.
|
||||
*
|
||||
* @param mcpServer - The MCP server instance
|
||||
*/
|
||||
constructor(mcpServer: PenpotMcpServer) {
|
||||
super(mcpServer, ImportImageArgs.schema);
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "import_image";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
return (
|
||||
"Imports a pixel image from the local file system into Penpot by creating a Rectangle instance " +
|
||||
"that uses the image as a fill. The rectangle has the image's original proportions by default. " +
|
||||
"Optionally accepts position (x, y) and dimensions (width, height) parameters. " +
|
||||
"If only one dimension is provided, the other is calculated to maintain the image's aspect ratio. " +
|
||||
"Supported formats: JPEG, PNG, GIF, WEBP."
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeCore(args: ImportImageArgs): Promise<ToolResponse> {
|
||||
// check that file path is absolute
|
||||
FileUtils.checkPathIsAbsolute(args.filePath);
|
||||
|
||||
// check that file exists
|
||||
if (!fs.existsSync(args.filePath)) {
|
||||
throw new Error(`File not found: ${args.filePath}`);
|
||||
}
|
||||
|
||||
// read the file as binary data
|
||||
const fileData = fs.readFileSync(args.filePath);
|
||||
const base64Data = fileData.toString("base64");
|
||||
|
||||
// determine mime type from file extension
|
||||
const ext = path.extname(args.filePath).toLowerCase();
|
||||
const mimeType = ImportImageTool.MIME_TYPES[ext];
|
||||
if (!mimeType) {
|
||||
const supportedExtensions = Object.keys(ImportImageTool.MIME_TYPES).join(", ");
|
||||
throw new Error(
|
||||
`Unsupported image format: ${ext}. Supported formats (file extensions): ${supportedExtensions}`
|
||||
);
|
||||
}
|
||||
|
||||
// generate and execute JavaScript code to import the image
|
||||
const fileName = path.basename(args.filePath);
|
||||
const escapedBase64 = base64Data.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const escapedFileName = fileName.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const code = `
|
||||
const rectangle = await penpotUtils.importImage(
|
||||
'${escapedBase64}', '${mimeType}', '${escapedFileName}',
|
||||
${args.x ?? "undefined"}, ${args.y ?? "undefined"},
|
||||
${args.width ?? "undefined"}, ${args.height ?? "undefined"});
|
||||
return { shapeId: rectangle.id };
|
||||
`;
|
||||
const task = new ExecuteCodePluginTask({ code: code });
|
||||
const executionResult = await this.mcpServer.pluginBridge.executePluginTask(task);
|
||||
|
||||
return new TextResponse(JSON.stringify(executionResult.data?.result, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "../Tool";
|
||||
import "reflect-metadata";
|
||||
import type { ToolResponse } from "../ToolResponse";
|
||||
import { TextResponse } from "../ToolResponse";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
import { ApiDocs } from "../ApiDocs";
|
||||
|
||||
/**
|
||||
* Arguments class for the PenpotApiInfoTool
|
||||
*/
|
||||
export class PenpotApiInfoArgs {
|
||||
static schema = {
|
||||
type: z.string().min(1, "Type name cannot be empty"),
|
||||
member: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* The API type name to retrieve information for.
|
||||
*/
|
||||
type!: string;
|
||||
|
||||
/**
|
||||
* The specific member name to retrieve (optional).
|
||||
*/
|
||||
member?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for retrieving Penpot API documentation information.
|
||||
*
|
||||
* This tool provides access to API type documentation loaded from YAML files,
|
||||
* allowing retrieval of either full type documentation or specific member details.
|
||||
*/
|
||||
export class PenpotApiInfoTool extends Tool<PenpotApiInfoArgs> {
|
||||
private static readonly MAX_FULL_TEXT_CHARS = 2000;
|
||||
private readonly apiDocs: ApiDocs;
|
||||
|
||||
/**
|
||||
* Creates a new PenpotApiInfo tool instance.
|
||||
*
|
||||
* @param mcpServer - The MCP server instance
|
||||
*/
|
||||
constructor(mcpServer: PenpotMcpServer, apiDocs: ApiDocs) {
|
||||
super(mcpServer, PenpotApiInfoArgs.schema);
|
||||
this.apiDocs = apiDocs;
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "penpot_api_info";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
return (
|
||||
"Retrieves Penpot API documentation for types and their members." +
|
||||
"Be sure to read the 'Penpot High-Level Overview' first."
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeCore(args: PenpotApiInfoArgs): Promise<ToolResponse> {
|
||||
const apiType = this.apiDocs.getType(args.type);
|
||||
|
||||
if (!apiType) {
|
||||
throw new Error(`API type "${args.type}" not found`);
|
||||
}
|
||||
|
||||
if (args.member) {
|
||||
// return specific member documentation
|
||||
const memberDoc = apiType.getMember(args.member);
|
||||
if (!memberDoc) {
|
||||
throw new Error(`Member "${args.member}" not found in type "${args.type}"`);
|
||||
}
|
||||
return new TextResponse(memberDoc);
|
||||
} else {
|
||||
// return full text or overview based on length
|
||||
const fullText = apiType.getFullText();
|
||||
if (fullText.length <= PenpotApiInfoTool.MAX_FULL_TEXT_CHARS) {
|
||||
return new TextResponse(fullText);
|
||||
} else {
|
||||
return new TextResponse(
|
||||
apiType.getOverviewText() +
|
||||
"\n\nMember details not provided (too long). " +
|
||||
"Call this tool with a member name for more information."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
export class FileUtils {
|
||||
/**
|
||||
* Checks whether the given file path is absolute and raises an error if not.
|
||||
*
|
||||
* @param filePath - The file path to check
|
||||
*/
|
||||
public static checkPathIsAbsolute(filePath: string): void {
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
throw new Error(`The specified file path must be absolute: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static createParentDirectories(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes binary data to a file at the specified path, creating the parent directories if necessary.
|
||||
*
|
||||
* @param filePath - The absolute path to the file where data should be written
|
||||
* @param bytes - The binary data to write to the file
|
||||
*/
|
||||
public static writeBinaryFile(filePath: string, bytes: Uint8Array): void {
|
||||
this.createParentDirectories(filePath);
|
||||
fs.writeFileSync(filePath, Buffer.from(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes text data to a file at the specified path, creating the parent directories if necessary.
|
||||
*
|
||||
* @param filePath - The absolute path to the file where data should be written
|
||||
* @param text - The text data to write to the file
|
||||
*/
|
||||
public static writeTextFile(filePath: string, text: string): void {
|
||||
this.createParentDirectories(filePath);
|
||||
fs.writeFileSync(filePath, text, { encoding: "utf-8" });
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2
mcp/types-generator/.gitattributes
vendored
2
mcp/types-generator/.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# SCM syntax highlighting
|
||||
pixi.lock linguist-language=YAML linguist-generated=true
|
||||
4
mcp/types-generator/.gitignore
vendored
4
mcp/types-generator/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
|
||||
# pixi environments
|
||||
.pixi
|
||||
*.egg-info
|
||||
@@ -1,27 +0,0 @@
|
||||
# Types Generator
|
||||
|
||||
This subproject contains helper scripts used in the development of the
|
||||
Penpot MCP server for generate the types yaml.
|
||||
|
||||
## Setup
|
||||
|
||||
This project uses [pixi](https://pixi.sh) for environment management
|
||||
(already included in devenv).
|
||||
|
||||
Install the environment via
|
||||
|
||||
pixi install
|
||||
|
||||
## Scripts
|
||||
|
||||
### Preparation of API Documentation for the MCP Server
|
||||
|
||||
The script `prepare_api_docs.py` reads API documentation from the Web
|
||||
and collects it in a single yaml file, which is then used by an MCP
|
||||
tool to provide API documentation to an LLM on demand.
|
||||
|
||||
Running the script:
|
||||
|
||||
pixi run python prepare_api_docs.py
|
||||
|
||||
This will generate `../mcp-server/data/api_types.yml`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user