Add export tokens modal with multi-file export (#6649)

This commit is contained in:
Florian Schrödl
2025-06-06 15:37:10 +02:00
committed by GitHub
parent fec7d5cff2
commit bb9daf7c03
10 changed files with 403 additions and 21 deletions

View File

@@ -28,6 +28,7 @@ on [its own changelog](library/CHANGES.md)
- Support system color scheme [Github #5030](https://github.com/penpot/penpot/issues/5030)
- Persist ruler visibility across files and reloads [GitHub #4586](https://github.com/penpot/penpot/issues/4586)
- Update google fonts (at 2025/05/19) [Taiga 10792](https://tree.taiga.io/project/penpot/us/10792)
- Adds tokens multi file export [Github #117](https://github.com/tokens-studio/penpot/issues/117)
### :bug: Bugs fixed
- Fix getCurrentUser for plugins api [Taiga #11057](https://tree.taiga.io/project/penpot/issue/11057)

View File

@@ -98,7 +98,7 @@
(defn encode
[data & {:as opts}]
#?(:clj (j/write-str data opts)
:cljs (.stringify js/JSON (->js data opts))))
:cljs (.stringify js/JSON (->js data opts) nil (:indent opts))))
(defn decode
[data & {:as opts}]

View File

@@ -1420,8 +1420,13 @@ Will return a value that matches this schema:
:else
(parse-multi-set-dtcg-json decoded-json))))
(defn export-dtcg-json
"Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi sets json string in DTCG format."
(defn- token->dtcg-token [token]
(cond-> {"$value" (:value token)
"$type" (cto/token-type->dtcg-token-type (:type token))}
(:description token) (assoc "$description" (:description token))))
(defn- dtcg-export-themes
"Extract themes for a dtcg json export."
[tokens-lib]
(let [themes-xform
(comp
@@ -1443,22 +1448,37 @@ Will return a value that matches this schema:
(into [] themes-xform))
;; Active themes without exposing hidden penpot theme
active-themes-clear
active-themes
(-> (get-active-theme-paths tokens-lib)
(disj hidden-theme-path))
(disj hidden-theme-path))]
{:themes themes
:active-themes active-themes}))
update-token-fn
(fn [token]
(cond-> {"$value" (:value token)
"$type" (cto/token-type->dtcg-token-type (:type token))}
(:description token) (assoc "$description" (:description token))))
(defn export-dtcg-multi-file
"Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi json files each encoded in DTCG format."
[tokens-lib]
(let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib)
sets (->> (get-sets tokens-lib)
(map (fn [{:keys [name tokens]}]
[(str name ".json") (tokens-tree tokens :update-token-fn token->dtcg-token)]))
(into {}))]
(-> sets
(assoc "$themes.json" themes)
(assoc "$metadata.json" {"tokenSetOrder" (get-ordered-set-names tokens-lib)
"activeThemes" active-themes
"activeSets" (get-active-themes-set-names tokens-lib)}))))
(defn export-dtcg-json
"Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi sets json string in DTCG format."
[tokens-lib]
(let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib)
name-set-tuples
(->> (get-set-tree tokens-lib)
(tree-seq d/ordered-map? vals)
(filter (partial instance? TokenSet))
(map (fn [{:keys [name tokens]}]
[name (tokens-tree tokens :update-token-fn update-token-fn)])))
[name (tokens-tree tokens :update-token-fn token->dtcg-token)])))
ordered-set-names
(mapv first name-set-tuples)
@@ -1471,9 +1491,9 @@ Will return a value that matches this schema:
(-> sets
(assoc "$themes" themes)
(assoc-in ["$metadata" "tokenSetOrder"] ordered-set-names)
(assoc-in ["$metadata" "activeThemes"] active-themes-clear)
(assoc-in ["$metadata" "activeSets"] active-set-names))))
(assoc "$metadata" {"tokenSetOrder" ordered-set-names
"activeThemes" active-themes
"activeSets" active-set-names}))))
(defn get-tokens-of-unknown-type
"Search for all tokens in the decoded json file that have a type that is not currently

View File

@@ -1507,3 +1507,56 @@
"$type" "color"
"$description" ""}}}}}]
(t/is (= expected result)))))
#?(:clj
(t/deftest export-dtcg-multi-file
(let [now (dt/now)
tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "some/set"
:tokens {"colors.red.600"
(ctob/make-token
{:name "colors.red.600"
:type :color
:value "#e53e3e"})
"spacing.multi-value"
(ctob/make-token
{:name "spacing.multi-value"
:type :spacing
:value "{dimension.sm} {dimension.xl}"
:description "You can have multiple values in a single spacing token"})
"button.primary.background"
(ctob/make-token
{:name "button.primary.background"
:type :color
:value "{accent.default}"})}))
(ctob/add-theme (ctob/make-token-theme :name "theme-1"
:group "group-1"
:external-id "test-id-01"
:modified-at now
:sets #{"some/set"}))
(ctob/toggle-theme-active? "group-1" "theme-1"))
result (ctob/export-dtcg-multi-file tokens-lib)
expected {"$themes.json" [{"description" ""
"group" "group-1"
"is-source" false
"modified-at" now
"id" "test-id-01"
"name" "theme-1"
"selectedTokenSets" {"some/set" "enabled"}}]
"$metadata.json" {"tokenSetOrder" ["some/set"]
"activeThemes" #{"group-1/theme-1"}
"activeSets" #{"some/set"}}
"some/set.json"
{"colors" {"red" {"600" {"$value" "#e53e3e"
"$type" "color"
"$description" ""}}}
"spacing"
{"multi-value"
{"$value" "{dimension.sm} {dimension.xl}"
"$type" "spacing"
"$description" "You can have multiple values in a single spacing token"}}
"button"
{"primary" {"background" {"$value" "{accent.default}"
"$type" "color"
"$description" ""}}}}}]
(t/is (= expected result)))))

View File

@@ -33,6 +33,7 @@
[app.main.ui.workspace.sidebar.collapsable-button :refer [collapsed-button]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
[app.main.ui.workspace.tokens.modals]
[app.main.ui.workspace.tokens.modals.export]
[app.main.ui.workspace.tokens.modals.import]
[app.main.ui.workspace.tokens.modals.settings]
[app.main.ui.workspace.tokens.modals.themes]

View File

@@ -0,0 +1,144 @@
;; 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.main.ui.workspace.tokens.modals.export
(:require-macros [app.main.style :as stl])
(:require
[app.common.json :as json]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.ui.components.code-block :refer [code-block]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[app.util.zip :as zip]
[rumext.v2 :as mf]))
(mf/defc export-tab*
{::mf/private true}
[{:keys [on-export is-disabled children]}]
[:div {:class (stl/css :export-preview)}
(when-not is-disabled
[:> text* {:as "span" :typography "body-medium" :class (stl/css :preview-label)}
(tr "workspace.tokens.export.preview")])
(if is-disabled
[:div {:class (stl/css :disabled-message)}
(tr "workspace.tokens.export.no-tokens-themes-sets")]
children)
[:div {:class (stl/css :export-actions)}
[:> button* {:variant "secondary"
:type "button"
:on-click modal/hide!}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:type "button"
:disabled is-disabled
:on-click on-export}
(tr "workspace.tokens.export")]]])
(mf/defc single-file-tab*
{::mf/private true}
[]
(let [tokens-data (some-> (deref refs/tokens-lib)
(ctob/export-dtcg-json))
tokens-json (some-> tokens-data
(json/encode :key-fn identity :indent 2))
is-disabled (empty? tokens-data)
on-export
(mf/use-fn
(mf/deps tokens-json)
(fn []
(when tokens-json
(->> (wapi/create-blob (or tokens-json "{}") "application/json")
(dom/trigger-download "tokens.json")))))]
[:> export-tab* {:is-disabled is-disabled
:on-export on-export}
[:div {:class (stl/css :json-preview)}
[:> code-block {:code tokens-json :type "json"}]]]))
(defn download-tokens-zip! [multi-file-entries]
(let [writer (-> (zip/blob-writer {:mtype "application/zip"})
(zip/writer))]
(doseq [[path content] multi-file-entries]
(zip/add writer path (json/encode content :key-fn identity :indent 2)))
(-> (zip/close writer)
(.then #(dom/trigger-download "tokens.zip" %)))))
(mf/defc multi-file-tab*
{::mf/private true}
[]
(let [files (some->> (deref refs/tokens-lib)
(ctob/export-dtcg-multi-file))
is-disabled (or (empty? files)
(every? (fn [[_ v]] (empty? v)) files))
on-export
(mf/use-fn
(mf/deps files)
(fn []
(download-tokens-zip! files)))]
[:> export-tab* {:on-export on-export
:is-disabled is-disabled}
[:div {:class (stl/css :preview-container)}
[:ul {:class (stl/css :file-list)}
(for [[path] files]
[:li {:key path
:class (stl/css :file-item)}
[:div {:class (stl/css :file-icon)}
[:> icon* {:icon-id "document"}]]
[:div {:class (stl/css :file-name) :title path}
path]])]]]))
(mf/defc export-modal-body*
{::mf/private true}
[]
(let [selected-tab (mf/use-state "single-file")
on-change-tab
(mf/use-fn
(fn [tab-id]
(reset! selected-tab tab-id)))
single-file-content
(mf/html [:> single-file-tab*])
multiple-files-content
(mf/html [:> multi-file-tab*])
tabs #js [#js {:label (tr "workspace.tokens.export.single-file")
:id "single-file"
:content single-file-content}
#js {:label (tr "workspace.tokens.export.multiple-files")
:id "multiple-files"
:content multiple-files-content}]]
[:div {:class (stl/css :export-modal-wrapper)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :export-modal-title)}
(tr "workspace.tokens.export-tokens")]
[:> tab-switcher*
{:tabs tabs
:selected @selected-tab
:on-change-tab on-change-tab}]]))
(mf/defc export-modal*
{::mf/register modal/components
::mf/register-as :tokens/export}
[]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)}
[:> icon-button* {:class (stl/css :close-btn)
:on-click modal/hide!
:aria-label (tr "labels.close")
:variant "ghost"
:icon "close"}]
[:> export-modal-body*]]])

View File

@@ -0,0 +1,125 @@
// 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
@use "../../../ds/typography.scss" as t;
@use "../../../ds/_sizes.scss" as *;
@use "../../../ds/_borders.scss" as *;
@import "refactor/common-refactor.scss";
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-dialog {
--modal-width: 32rem;
--modal-padding: var(--sp-xxxl);
--container-max-height: 16rem;
@extend .modal-container-base;
user-select: none;
width: var(--modal-width);
max-width: 100%;
}
.export-modal-wrapper {
display: flex;
flex-direction: column;
gap: var(--sp-xxl);
}
.export-modal-title {
color: var(--color-foreground-primary);
}
.export-preview {
display: flex;
flex-direction: column;
gap: var(--sp-m);
padding-top: var(--sp-m);
}
.preview-label {
color: var(--color-foreground-secondary);
}
.preview-container {
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
overflow-y: auto;
padding: var(--sp-xs) var(--sp-m);
max-height: var(--container-max-height);
}
.file-list {
width: 100%;
margin-bottom: 0;
overflow-y: auto;
}
.file-item {
display: flex;
align-items: center;
width: 100%;
cursor: default;
color: var(--color-foreground-secondary);
border: $br-2 solid transparent;
}
.file-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.file-name {
@include textEllipsis;
@include t.use-typography("body-medium");
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: var(--sp-s);
}
.export-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-s);
}
.close-btn {
position: absolute;
inset-block-start: var(--sp-s);
inset-inline-end: var(--sp-s);
}
.json-preview {
width: 100%;
}
.json-preview pre {
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
margin: 0;
max-height: var(--container-max-height);
overflow-y: auto;
overflow-x: auto;
word-wrap: normal;
white-space: pre;
max-width: calc(var(--modal-width) - var(--modal-padding) * 2);
}
.disabled-message {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
display: flex;
align-items: center;
justify-content: center;
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
overflow-y: auto;
padding: var(--sp-s) var(--sp-m);
max-height: var(--container-max-height);
}

View File

@@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.json :as json]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.data.event :as ev]
@@ -37,7 +36,6 @@
[app.util.array :as array]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[okulary.core :as l]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]
@@ -387,11 +385,7 @@
(mf/use-fn
(fn []
(st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens"}))
(let [tokens-json (some-> (deref refs/tokens-lib)
(ctob/export-dtcg-json)
(json/encode :key-fn identity))]
(->> (wapi/create-blob (or tokens-json "{}") "application/json")
(dom/trigger-download "tokens.json")))))
(modal/show! :tokens/export {})))
on-modal-show
(mf/use-fn

View File

@@ -7249,6 +7249,30 @@ msgstr "Importing a JSON file will override all your current tokens, sets and th
msgid "workspace.tokens.import-warning"
msgstr "Importing tokens will override all your current tokens, sets and themes."
#: src/app/main/ui/workspace/tokens/modals/export.cljs:74
msgid "workspace.tokens.export"
msgstr "Export"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:47
msgid "workspace.tokens.export-tokens"
msgstr "Export tokens"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:51
msgid "workspace.tokens.export.single-file"
msgstr "Single file"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:54
msgid "workspace.tokens.export.multiple-files"
msgstr "Multiple files"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:60
msgid "workspace.tokens.export.preview"
msgstr "Preview:"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:37
msgid "workspace.tokens.export.no-tokens-themes-sets"
msgstr "There are no tokens, themes or sets to export."
#: src/app/main/ui/workspace/tokens/sidebar.cljs:341
msgid "workspace.tokens.inactive-set"
msgstr "Inactive"

View File

@@ -7265,6 +7265,26 @@ msgstr "Al importar un fichero JSON sobreescribirás todos tus tokens, sets y th
msgid "workspace.tokens.import-warning"
msgstr "Al importar tokens sobreescribirás todos tus tokens, sets y themes."
#: src/app/main/ui/workspace/tokens/modals/export.cljs:74
msgid "workspace.tokens.export"
msgstr "Exportar"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:51
msgid "workspace.tokens.export.single-file"
msgstr "fichero único"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:54
msgid "workspace.tokens.export.multiple-files"
msgstr "Múltiples ficheros"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:60
msgid "workspace.tokens.export.preview"
msgstr "Previsualizar:"
#: src/app/main/ui/workspace/tokens/modals/export.cljs:37
msgid "workspace.tokens.export.no-tokens-themes-sets"
msgstr "No existen tokens, temas o sets para exportar."
#: src/app/main/ui/workspace/tokens/sidebar.cljs:341
msgid "workspace.tokens.inactive-set"
msgstr "Inactivo"