mirror of
https://github.com/penpot/penpot.git
synced 2026-09-25 13:26:16 +00:00
🐛 Fix font family space in tokens
This commit is contained in:
parent
85bba64209
commit
77e1fa6401
@ -8,9 +8,12 @@
|
|||||||
processTokens
|
processTokens
|
||||||
TokenSymbol
|
TokenSymbol
|
||||||
makeConfig]]
|
makeConfig]]
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.main.data.workspace.tokens.errors :as wte]))
|
[app.common.types.token :as cto]
|
||||||
|
[app.main.data.workspace.tokens.errors :as wte]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
(l/set-level! :debug)
|
(l/set-level! :debug)
|
||||||
|
|
||||||
@ -78,6 +81,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.
|
||||||
|
Each family name is the string form of its whole entry, which is a list of
|
||||||
|
word symbols when the family reached tokenscript unquoted."
|
||||||
|
[^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 +99,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 +117,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
|
||||||
@ -130,11 +157,45 @@
|
|||||||
:onError on-error
|
:onError on-error
|
||||||
:getResult get-result}))
|
:getResult get-result}))
|
||||||
|
|
||||||
|
(defn- quote-font-family
|
||||||
|
"Quotes a font family name so tokenscript reads it as one string instead of
|
||||||
|
parsing its words (`Red` would become a color, `2P` a number and a word).
|
||||||
|
Entries with a token reference or already quoted are kept as they are."
|
||||||
|
[family]
|
||||||
|
(cond
|
||||||
|
(or (not (string? family))
|
||||||
|
(seq (cto/find-token-value-references family))
|
||||||
|
(re-matches #"^(['\"]).*\1$" family))
|
||||||
|
family
|
||||||
|
|
||||||
|
(not (str/includes? family "\""))
|
||||||
|
(str "\"" family "\"")
|
||||||
|
|
||||||
|
(not (str/includes? family "'"))
|
||||||
|
(str "'" family "'")
|
||||||
|
|
||||||
|
:else
|
||||||
|
family))
|
||||||
|
|
||||||
|
(defn- quote-font-families
|
||||||
|
[value]
|
||||||
|
(if (sequential? value)
|
||||||
|
(mapv quote-font-family value)
|
||||||
|
value))
|
||||||
|
|
||||||
|
(defn- token-value->tokenscript-value
|
||||||
|
[type value]
|
||||||
|
(case type
|
||||||
|
:font-family (quote-font-families value)
|
||||||
|
:typography (cond-> value
|
||||||
|
(map? value) (d/update-when :font-family quote-font-families))
|
||||||
|
value))
|
||||||
|
|
||||||
(defn clj->token->tokenscript-token
|
(defn clj->token->tokenscript-token
|
||||||
"Convert penpot token into a format that tokenscript can handle."
|
"Convert penpot token into a format that tokenscript can handle."
|
||||||
[{:keys [type value]}]
|
[{:keys [type value]}]
|
||||||
#js {"$type" (name type)
|
#js {"$type" (name type)
|
||||||
"$value" (clj->js value)})
|
"$value" (clj->js (token-value->tokenscript-value type value))})
|
||||||
|
|
||||||
(defn clj-tokens->tokenscript-tokens
|
(defn clj-tokens->tokenscript-tokens
|
||||||
"Convert penpot map of tokens into tokenscript map structure.
|
"Convert penpot map of tokens into tokenscript map structure.
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -194,7 +194,8 @@
|
|||||||
(when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)]
|
(when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)]
|
||||||
(->> (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]
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -149,13 +149,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]
|
||||||
|
|||||||
@ -422,6 +422,71 @@
|
|||||||
(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 font-family-token-resolved-value-keeps-names-with-expression-words
|
||||||
|
;; Words like `Red` or `Black` read as colors and `2P` as a number and a word
|
||||||
|
;; when tokenscript parses the family name.
|
||||||
|
(let [families ["Red Hat Display" "Crimson Pro" "Archivo Black"
|
||||||
|
"Press Start 2P" "M PLUS 1p" "Crimson"]
|
||||||
|
token (ctob/make-token
|
||||||
|
{:name "font.display"
|
||||||
|
:type :font-family
|
||||||
|
:value families})
|
||||||
|
result (get-resolved-value token {(:name token) token})]
|
||||||
|
(t/is (= families (vec result)))))
|
||||||
|
|
||||||
|
(t/deftest font-family-token-resolved-value-mixes-references-and-names
|
||||||
|
(let [base (ctob/make-token
|
||||||
|
{:name "font.base"
|
||||||
|
:type :font-family
|
||||||
|
:value ["Red Hat Text"]})
|
||||||
|
token (ctob/make-token
|
||||||
|
{:name "font.stack"
|
||||||
|
:type :font-family
|
||||||
|
:value ["{font.base}" "Rock 3D"]})
|
||||||
|
result (get-resolved-value token {(:name base) base
|
||||||
|
(:name token) token})]
|
||||||
|
(t/is (= ["Red Hat Text" "Rock 3D"] (vec result)))))
|
||||||
|
|
||||||
|
(t/deftest font-family-token-resolved-value-keeps-quoted-names
|
||||||
|
(let [token (ctob/make-token
|
||||||
|
{:name "font.quoted"
|
||||||
|
:type :font-family
|
||||||
|
:value ["\"Red Hat Mono\"" "'Black Ops One'"]})
|
||||||
|
result (get-resolved-value token {(:name token) token})]
|
||||||
|
(t/is (= ["Red Hat Mono" "Black Ops One"] (vec result)))))
|
||||||
|
|
||||||
|
(t/deftest typography-token-resolved-value-keeps-names-with-expression-words
|
||||||
|
(let [token (ctob/make-token
|
||||||
|
{:name "type.display"
|
||||||
|
:type :typography
|
||||||
|
:value {:font-family ["Red Hat Display" "Press Start 2P"]
|
||||||
|
:font-size "16px"}})
|
||||||
|
result (get-resolved-value token {(:name token) token})
|
||||||
|
entry (aget result 0)]
|
||||||
|
(t/is (= ["Red Hat Display" "Press Start 2P"]
|
||||||
|
(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)
|
||||||
|
|||||||
@ -15,6 +15,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, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged.
|
||||||
|
|
||||||
## 1.5.0 (2026-07-08)
|
## 1.5.0 (2026-07-08)
|
||||||
|
|
||||||
|
|||||||
@ -306,6 +306,31 @@ 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.
|
||||||
|
// The family comes from the bundled Google fonts catalog, available in every
|
||||||
|
// run. `Red` in its name reads as a color if the resolver parses the words.
|
||||||
|
test('applyToken binds a fontFamilies token to a text shape', async (ctx) => {
|
||||||
|
const set = activeSet(ctx, unique('set'));
|
||||||
|
const font = ctx.penpot.fonts.findByName('Red Hat Display');
|
||||||
|
if (!font) throw new Error('Red Hat Display font not found');
|
||||||
|
const token = set.addToken({
|
||||||
|
type: 'fontFamilies',
|
||||||
|
name: unique('fontFamilies.'),
|
||||||
|
value: [font.fontFamily],
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => t.fontFamily === font.fontFamily);
|
||||||
|
expect(Object.keys(t.tokens)).toContain('fontFamilies');
|
||||||
|
expect(t.fontFamily).toBe(font.fontFamily);
|
||||||
|
});
|
||||||
|
|
||||||
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 +425,39 @@ 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Words like `Red` or `Black` read as colors and `2P` as a number and a word
|
||||||
|
// when the resolver parses the family name.
|
||||||
|
test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => {
|
||||||
|
const set = activeSet(ctx, unique('set'));
|
||||||
|
const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P'];
|
||||||
|
const token = set.addToken({
|
||||||
|
type: 'fontFamilies',
|
||||||
|
name: unique('fontFamilies.'),
|
||||||
|
value: families,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token.resolvedValue as unknown as string[]).toEqual(families);
|
||||||
|
});
|
||||||
|
|
||||||
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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user