style-dictionary
style-dictionary copied to clipboard
Transforms that run after all references have been resolved
I'd like to propose adding a property to transforms that indicates they ought to be ran after references have been resolved entirely.
This means that transforms can be ran in 3 different ways (in chronological order), rather than only 2:
- default, which is that the transforms run before any reference resolving has happened, and they only apply to token values that do not contain references
- transitive
true
, which means that the transform puts properties with references in a deferred state, after which goes into a repetitive cycle of resolving the reference for such properties and attempting to transform it once again. If the resolved value is another chained reference, this cycle continues until the resolved value is not a reference, after which the transitive transform is finally applied to it. See example below. - (proposing to add) postTransitive
true
which means this transform is ran at the very end when all references have been resolved and all regular and transitive transforms have done their jobs.
Example transitive transform
Imagine the tokens below and a transitive transform that transforms the token value by a darken value, to darken the color.
{
"color": {
"red": { "value": "#f00" },
"danger": { "value": "{color.red}", "darken": 0.75 },
"error": { "value": "{color.danger}", "darken": 0.5 }
}
}
In precise detail, events happen in the following order
- red is "transformed", but there is no "darken" so nothing happens.
- danger is skipped because it has a reference, but the property is added to deferred props for later transformation
["color.danger"]
. - error is skipped because it has a reference, but the property is added to deferred props for later transformation
["color.danger", "color.error"]
. - Deferred props
["color.danger", "color.error"]
are added to exclusion list for referencing, so any value that is a reference to these, we need to hold off because we first need to apply transitive transforms to our first layer of refs, a reference to{color.red}
for example. - Resolving all references with the exception of ignorelist, which means
{color.red}
is resolved to#f00
, butcolor.error.value
which has{color.danger}
is skipped for now, because that one is in the ignorelist. - Apply transforms again, skipping tokens that were already transformed, but this time
color.danger
value is not a reference anymore. the original value is, so we only apply transitive transforms, meaning the value is darkened by0.75
. - error is skipped again because it still has a reference, so it's still a deferred prop for later transformation
["color.error"]
. - since we still have deferred prop
["color.error"]
, we do another resolve references call but this time the ignorelist only containscolor.error
becausecolor.danger
does not use a reference any longer. - this means that reference
color.danger
withincolor.error
is resolved to whatever is the current value ofcolor.danger
, which is the#00
but by now it is darkened by0.75
. - Apply transforms again, skipping tokens that were already transformed, but this time
color.error
value is not a reference anymore. the original value is, so we only apply transitive transforms, meaning the value is darkened by0.5
. - We do a final call of resolving references, this time with no deferred props and so, an empty ignore list, but it doesn't really matter since all references are already resolved at this point, so this call is mostly redundant.
- Because there are no deferred props, we are finished :slightly_smiling_face:.
Example post-transitive transform
Input is the same:
{
"color": {
"red": { "value": "#f00" },
"danger": { "value": "{color.red}", "darken": 0.75 },
"error": { "value": "{color.danger}", "darken": 0.5 }
}
}
However, the output is different, we now want:
import SwiftUI
public class {
public static let colorRed = UIColor(red: 255, green: 0, blue: 0, alpha: 1);
public static let colorDanger = UIColor(red: 63.75, green: 0, blue: 0, alpha: 1);
public static let colorError = UIColor(red: 31.88, green: 0, blue: 0, alpha: 1);
}
Which means we have the transitive transform for the darken color modifier running, but we also have a transform running that needs to transform the format into swift UIColor format.
The problem is that we cannot at this time add a transform that runs after the color modifications have ran, so what happens now is:
- color.red is transformed to UIColor format
- color.danger is deferred because it contains a reference, --> also added to ignorelist
- color.error is deferred because it contains a reference --> also added to ignorelist
-
color.danger
's reference tocolor.red
is resolved, but at this point this isUIColor(red: 255, green: 0, blue: 0, alpha: 1)
- transitive transform color modifier is applied to
color.danger
but this color modifier doesn't understand UIColor format :( fatal error! -
color.error
's reference tocolor.danger
would be skipped due to ignorelist, would only happen in the next iteration if step 5 didn't fail. And this would then fail for the same reason.
Hopefully this makes clear the use case for having post-transitive transforms.
Maybe the prop can be called deferred: true|false
btw, i think that's better than postTransitive
Here's another example that showcases the need to defer transformation until after all references are resolved when trying to build a transform that wraps math expressions inside calc()
.
{
"dimension": {
"scale": {
"value": "2",
"type": "sizing"
},
"xs": {
"value": "4px",
"type": "sizing"
},
"sm": {
"value": "{dimension.xs} * {dimension.scale}",
"type": "sizing"
},
"md": {
"value": "{dimension.sm} * {dimension.scale}",
"type": "sizing"
},
"lg": {
"value": "{dimension.md} * {dimension.scale}",
"type": "sizing"
},
}
}
StyleDictionary.registerTransform({
type: `value`,
transitive: true,
name: `figma/calc`,
matcher: ({ value }) => typeof value === 'string' && value.includes('*') && !value.includes('calc('),
transformer: ({ value }) => `calc(${value})`,
});
Expected output:
:root {
--sd-dimension-scale: 2;
--sd-dimension-xs: 4;
--sd-dimension-sm: calc(4px * 2);
--sd-dimension-md: calc(4px * 2 * 2);
--sd-dimension-lg: calc(4px * 2 * 2 * 2);
}
Actual output:
:root {
--sd-dimension-scale: 2px;
--sd-dimension-xs: 4px;
--sd-dimension-sm: calc(4px * 2px);
--sd-dimension-md: calc(4px * 2px) * 2px;
--sd-dimension-lg: calc(4px * 2px) * 2px * 2px;
}
Which is easy to explain when you understand the lifecycle of transitive transforms:
- sm, md and lg are all deferred
- in the first cycle, sm is resolved ->
4px * 2
- sm is then transformed ->
calc(4px * 2)
- next cycle: md and lg are deferred
- md is resolved ->
calc(4px * 2) * 2
- md is not transformed because it already has calc(), otherwise we would get: calc(calc(4px * 2) * 2)
There's a solution where we can always apply the transform even if it already has a calc statement, after which we get:
:root {
--sd-dimension-scale: 2;
--sd-dimension-xs: 4px;
--sd-dimension-sm: calc(4px * 2);
--sd-dimension-md: calc(calc(4px * 2) * 2);
--sd-dimension-lg: calc(calc(calc(4px * 2) * 2) * 2);
}
Which is actually valid CSS, but it just contains a nested calc statement for every chain of reference, which is a bit bloated..
When we allow this transform to be deferred at the end, we can get the expected outcome instead, which I think is the best solution.