🐛 Fix font family space in tokens

This commit is contained in:
alonso.torres 2026-09-16 15:31:38 +02:00
parent 9a11dfa7f9
commit 7c4eae9b56
9 changed files with 99 additions and 13 deletions

View File

@ -78,6 +78,17 @@
(declare tokenscript-symbols->penpot-unit) (declare tokenscript-symbols->penpot-unit)
(defn font-family-symbols->penpot-unit
"Converts a resolved font-family value into a vector of family names.
A family whose name has several words (`Hanken Grotesk`) is a list of word
symbols, so each family name is the string form of the whole entry."
[^js v]
(when (some? v)
(let [entries (.-value v)]
(if (instance? js/Array entries)
(mapv str entries)
[(str v)]))))
(defn structured-token->penpot-map (defn structured-token->penpot-map
"Converts structured token (record or array) to penpot map format. "Converts structured token (record or array) to penpot map format.
Structured tokens are non-primitive token types like `typography` or `box-shadow`." Structured tokens are non-primitive token types like `typography` or `box-shadow`."
@ -85,9 +96,13 @@
(if (instance? js/Array (.-value token-symbol)) (if (instance? js/Array (.-value token-symbol))
(mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) (mapv tokenscript-symbols->penpot-unit (.-value token-symbol))
(let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))]
(into {} (map (fn [[k v :as V]] (into {} (map (fn [[k v]]
[(keyword k) (tokenscript-symbols->penpot-unit v)]) (let [k (keyword k)]
entries))))) ;; The font-family member of a composite is a family list
[k (if (= :font-family k)
(font-family-symbols->penpot-unit v)
(tokenscript-symbols->penpot-unit v))])))
entries))))
(defn tokenscript-symbols->penpot-unit [^js v] (defn tokenscript-symbols->penpot-unit [^js v]
(cond (cond
@ -99,6 +114,15 @@
(percent-number-with-unit? v) (/ (.-value v) 100) (percent-number-with-unit? v) (/ (.-value v) 100)
:else (.-value v))) :else (.-value v)))
(defn resolved-value->penpot-unit
"Converts the resolved value of a token of the given `type`.
Font families need the type: a list of symbols is a list of families at the
top level and the words of a single family name inside an entry."
[type ^js v]
(if (= :font-family type)
(font-family-symbols->penpot-unit v)
(tokenscript-symbols->penpot-unit v)))
;; Processors ------------------------------------------------------------------ ;; Processors ------------------------------------------------------------------
;; The processor resolves tokens ;; The processor resolves tokens
;; resolved/error tokens get put back into a clojure structure directly during build time ;; resolved/error tokens get put back into a clojure structure directly during build time

View File

@ -699,7 +699,7 @@
resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) resolved-value (get-in resolved-tokens [(:name token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript) resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value) (ts/resolved-value->penpot-unit (:type token) resolved-value)
resolved-value) resolved-value)
tokenized-attributes (cfo/attributes-map attributes token) tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)] type (:type token)]

View File

@ -193,7 +193,8 @@
(ctob/get-tokens-in-active-sets))] (ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript) (->> (if (contains? cf/flags :tokenscript)
(rx/of (-> (ts/resolve-tokens tokens-tree) (rx/of (-> (ts/resolve-tokens tokens-tree)
(d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) (d/update-vals #(update % :resolved-value
(partial ts/resolved-value->penpot-unit (:type %))))))
(sd/resolve-tokens tokens-tree)) (sd/resolve-tokens tokens-tree))
(rx/mapcat (rx/mapcat
(fn [sd-tokens] (fn [sd-tokens]

View File

@ -78,7 +78,7 @@
(fn [resolved-tokens] (fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript) resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value) (ts/resolved-value->penpot-unit (:type token) resolved-value)
resolved-value)] resolved-value)]
(if resolved-value (if resolved-value
(rx/of {:value resolved-value}) (rx/of {:value resolved-value})

View File

@ -46,7 +46,8 @@
(fn [resolved-tokens] (fn [resolved-tokens]
(let [resolved-token (cond-> (get resolved-tokens (:name token)) (let [resolved-token (cond-> (get resolved-tokens (:name token))
(contains? cf/flags :tokenscript) (contains? cf/flags :tokenscript)
(update :resolved-value ts/tokenscript-symbols->penpot-unit))] (update :resolved-value
(partial ts/resolved-value->penpot-unit (:type token))))]
(cond (cond
(:resolved-value resolved-token) (:resolved-value resolved-token)
(rx/of resolved-token) (rx/of resolved-token)

View File

@ -147,13 +147,10 @@
(defn- font-families-resolved-value->js (defn- font-families-resolved-value->js
"Converts a resolved fontFamilies value (a tokenscript list symbol) into the "Converts a resolved fontFamilies value (a tokenscript list symbol) into the
documented `string[]` shape rather than leaking the raw tokenscript structure." documented `string[]` shape."
[resolved-value] [resolved-value]
(let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] (some-> (ts/font-family-symbols->penpot-unit resolved-value)
(cond (clj->js)))
(nil? v) nil
(sequential? v) (clj->js v)
:else #js [v])))
(defn- get-resolved-value (defn- get-resolved-value
[token tokens-tree] [token tokens-tree]

View File

@ -410,6 +410,27 @@
(t/is (array? result)) (t/is (array? result))
(t/is (= ["Inter" "Arial"] (vec result))))) (t/is (= ["Inter" "Arial"] (vec result)))))
(t/deftest font-family-token-resolved-value-keeps-multi-word-families
;; An unquoted family whose name has several words resolves to a list of word
;; symbols, so the family name is the string form of each entry.
(let [token (ctob/make-token
{:name "font.body"
:type :font-family
:value ["Hanken Grotesk" "IBM Plex Mono"]})
result (get-resolved-value token {(:name token) token})]
(t/is (array? result))
(t/is (= ["Hanken Grotesk" "IBM Plex Mono"] (vec result)))))
(t/deftest typography-token-resolved-value-keeps-multi-word-families
(let [token (ctob/make-token
{:name "type.body"
:type :typography
:value {:font-family ["Hanken Grotesk" "Arial"]
:font-size "16px"}})
result (get-resolved-value token {(:name token) token})
entry (aget result 0)]
(t/is (= ["Hanken Grotesk" "Arial"] (vec (aget entry "fontFamilies"))))))
(t/deftest token-theme-add-set-accepts-token-set-id (t/deftest token-theme-add-set-accepts-token-set-id
(let [plugin-id "plugin-id" (let [plugin-id "plugin-id"
file-id (uuid/next) file-id (uuid/next)

View File

@ -14,6 +14,7 @@
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. - **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001. - **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. - **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words.
## 1.5.0 (2026-07-08) ## 1.5.0 (2026-07-08)

View File

@ -306,6 +306,28 @@ describe('Tokens', () => {
expect(Object.keys(b.tokens)).toContain('paddingLeft'); expect(Object.keys(b.tokens)).toContain('paddingLeft');
}); });
// The target is a text shape because the attribute set of a board excludes
// the font-family property, so the application is filtered out there and
// the assertions pass vacuously.
test('applyToken binds a fontFamilies token to a text shape', async (ctx) => {
const set = activeSet(ctx, unique('set'));
// Self-provided family, so the test doesn't depend on a specific font.
const family = ctx.penpot.fonts.all[0].fontFamily;
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: [family],
});
const t = ctx.penpot.createText('Hello Penpot');
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
t.applyToken(token, ['fontFamilies']);
await waitFor(() => Object.keys(t.tokens).includes('fontFamilies'));
expect(Object.keys(t.tokens)).toContain('fontFamilies');
});
test('duplicate and remove a token', (ctx) => { test('duplicate and remove a token', (ctx) => {
const set = activeSet(ctx, unique('set')); const set = activeSet(ctx, unique('set'));
const token = set.addToken({ const token = set.addToken({
@ -400,6 +422,25 @@ describe('Token types', () => {
expect(resolved as unknown as string[]).toContain('Arial'); expect(resolved as unknown as string[]).toContain('Arial');
}); });
// The resolver parses a family whose name has several words as a list of
// word symbols, so each family name must survive as a single entry. A
// single-word family takes the simpler path the test above covers.
test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: ['Hanken Grotesk'],
});
// The stored value keeps the family whole, isolating resolution below.
expect(token.value).toEqual(['Hanken Grotesk']);
const resolved = token.resolvedValue as unknown as string[];
expect(Array.isArray(resolved)).toBe(true);
expect(resolved).toHaveLength(1);
expect(resolved[0]).toBe('Hanken Grotesk');
});
test('shadow token exposes its composite value', (ctx) => { test('shadow token exposes its composite value', (ctx) => {
const set = activeSet(ctx, unique('set')); const set = activeSet(ctx, unique('set'));
const token = set.addToken({ const token = set.addToken({