amzn / style-dictionary

A build system for creating cross-platform styles.
https://styledictionary.com
Apache License 2.0
3.93k stars 557 forks source link

Transforms that run after all references have been resolved #1063

Open jorenbroekema opened 11 months ago

jorenbroekema commented 11 months ago

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:

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

  1. red is "transformed", but there is no "darken" so nothing happens.
  2. danger is skipped because it has a reference, but the property is added to deferred props for later transformation ["color.danger"].
  3. error is skipped because it has a reference, but the property is added to deferred props for later transformation ["color.danger", "color.error"].
  4. 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.
  5. Resolving all references with the exception of ignorelist, which means {color.red} is resolved to #f00, but color.error.value which has {color.danger} is skipped for now, because that one is in the ignorelist.
  6. 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 by 0.75.
  7. error is skipped again because it still has a reference, so it's still a deferred prop for later transformation ["color.error"].
  8. since we still have deferred prop ["color.error"], we do another resolve references call but this time the ignorelist only contains color.error because color.danger does not use a reference any longer.
  9. this means that reference color.danger within color.error is resolved to whatever is the current value of color.danger, which is the #00 but by now it is darkened by 0.75.
  10. 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 by 0.5.
  11. 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.
  12. 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:

  1. color.red is transformed to UIColor format
  2. color.danger is deferred because it contains a reference, --> also added to ignorelist
  3. color.error is deferred because it contains a reference --> also added to ignorelist
  4. color.danger's reference to color.red is resolved, but at this point this is UIColor(red: 255, green: 0, blue: 0, alpha: 1)
  5. transitive transform color modifier is applied to color.danger but this color modifier doesn't understand UIColor format :( fatal error!
  6. color.error's reference to color.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.

jorenbroekema commented 11 months ago

Maybe the prop can be called deferred: true|false btw, i think that's better than postTransitive

jorenbroekema commented 11 months ago

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:

  1. sm, md and lg are all deferred
  2. in the first cycle, sm is resolved -> 4px * 2
  3. sm is then transformed -> calc(4px * 2)
  4. next cycle: md and lg are deferred
  5. md is resolved -> calc(4px * 2) * 2
  6. 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.