mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
M3: Penpot tokens never reference a calculation a set leaves out (#1280)
A colour calculation that resolves to nothing in a mode is left out of that mode's token set, but
an alias to it still went out as a {name} reference to the missing token. Such an alias, and any
alias leading to one, now goes out as the colour it shows in the mode, or is left out with the
calculation's warning when it shows nothing. A calculation left out in only one mode of its
collection counts too: a reference to it from another collection would dangle whenever that
mode's theme is active.
This commit is contained in:
@@ -8,7 +8,7 @@ use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPositio
|
||||
use figcraft_doc::{
|
||||
BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing,
|
||||
LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration,
|
||||
TextStyle, VConstraint, VariableType, VariableValue,
|
||||
TextStyle, VConstraint, Variable, VariableType, VariableValue, Variables,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> {
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its
|
||||
/// variables are unchanged since, else one token set per collection (per mode, with a theme
|
||||
/// for each, when it has several). Aliases go out as `{name}` references and colour
|
||||
/// calculations as the colour they show; a token that resolves to nothing is left out with a
|
||||
/// warning, so no reference may name it.
|
||||
fn tokens_json(&mut self) -> Option<String> {
|
||||
let first = self.doc.pages.first().and_then(|p| self.node(*p));
|
||||
let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone()));
|
||||
@@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> {
|
||||
if variables.variables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let unreferenceable = unreferenceable(variables);
|
||||
let mut root = Map::new();
|
||||
let mut themes = Vec::new();
|
||||
let mut order = Vec::new();
|
||||
@@ -1726,13 +1732,26 @@ impl<'a> Exporter<'a> {
|
||||
let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() };
|
||||
let mut tokens = Map::new();
|
||||
for var in variables.variables.iter().filter(|v| v.collection == collection.id) {
|
||||
let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue };
|
||||
let Some(value) = mode_value(var, &mode.id) else { continue };
|
||||
// Penpot tokens have no shape for colour calculations: the colour they resolve
|
||||
// to in this mode goes out as a plain colour token.
|
||||
let Some(value) = variables.flattened(var, &mode.id, value) else {
|
||||
self.warn(crate::CALCULATIONS_LEFT_OUT);
|
||||
continue;
|
||||
};
|
||||
// A reference to a token some set leaves out would dangle whenever that set is
|
||||
// active: such an alias goes out as the colour it shows here instead.
|
||||
let value = match value {
|
||||
VariableValue::Alias(target) if unreferenceable.contains(target.as_str()) => {
|
||||
let here = BTreeMap::from([(collection.id.clone(), mode.id.clone())]);
|
||||
let Some(shown) = variables.resolve(&var.id, &here) else {
|
||||
self.warn(crate::CALCULATIONS_LEFT_OUT);
|
||||
continue;
|
||||
};
|
||||
shown
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let (kind, value) = match &value {
|
||||
VariableValue::Alias(target) => {
|
||||
let Some(target_name) = self.token_name(target) else { continue };
|
||||
@@ -1786,6 +1805,40 @@ impl<'a> Exporter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a variable's token takes in `mode` (its first value when it has none for that mode).
|
||||
fn mode_value<'v>(var: &'v Variable, mode: &str) -> Option<&'v VariableValue> {
|
||||
var.values.get(mode).or_else(|| var.values.values().next())
|
||||
}
|
||||
|
||||
/// Variables a token reference must not name: colour calculations some token set leaves out (they
|
||||
/// resolve to nothing in a mode of their collection) and every alias that leads to one.
|
||||
fn unreferenceable(variables: &Variables) -> BTreeSet<&str> {
|
||||
let mut out = BTreeSet::new();
|
||||
for var in &variables.variables {
|
||||
let Some(coll) = variables.collection_by_id(&var.collection) else { continue };
|
||||
if coll.modes.iter().any(|m| mode_value(var, &m.id).is_some_and(|v| variables.flattened(var, &m.id, v).is_none())) {
|
||||
out.insert(var.id.as_str());
|
||||
}
|
||||
}
|
||||
let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for var in &variables.variables {
|
||||
for value in var.values.values() {
|
||||
if let VariableValue::Alias(target) = value {
|
||||
readers.entry(target.as_str()).or_default().push(var.id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stack: Vec<&str> = out.iter().copied().collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
for reader in readers.get(id).into_iter().flatten() {
|
||||
if out.insert(reader) {
|
||||
stack.push(reader);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields.
|
||||
fn appearance_fields(node: &Node, fields: &mut Map<String, Value>) {
|
||||
if !node.visible {
|
||||
|
||||
@@ -588,7 +588,8 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
}
|
||||
|
||||
/// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows,
|
||||
/// and one that resolves to nothing is left out with a warning.
|
||||
/// and one that resolves to nothing is left out with a warning. No reference names a token a set
|
||||
/// leaves out: an alias to one goes out as the colour it shows, or is left out with it.
|
||||
#[test]
|
||||
fn colour_calculations_export_as_the_colour_they_show() {
|
||||
let mut doc = Document::new("Calculated");
|
||||
@@ -614,11 +615,34 @@ fn colour_calculations_export_as_the_colour_they_show() {
|
||||
&[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)],
|
||||
));
|
||||
let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 };
|
||||
doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone)]));
|
||||
doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())]));
|
||||
// Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with
|
||||
// one mode, which reads Color in its default mode, Light), to an alias that is left out, and to
|
||||
// one that always resolves.
|
||||
let alias = |to: &str| VariableValue::Alias(to.into());
|
||||
doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))]));
|
||||
let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 };
|
||||
doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)]));
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c2".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "one".into(), name: "One".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))]));
|
||||
doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))]));
|
||||
doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))]));
|
||||
let (bytes, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
|
||||
let tokens = tokens(&bytes);
|
||||
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written");
|
||||
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too");
|
||||
assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080");
|
||||
assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}");
|
||||
assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one");
|
||||
assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too");
|
||||
let text = tokens.to_string();
|
||||
assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}");
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");
|
||||
|
||||
Reference in New Issue
Block a user