tokens-studio / sd-transforms

Custom transforms for Style-Dictionary, to work with Design Tokens that are exported from Tokens Studio
MIT License
202 stars 29 forks source link

[Feature]: Allow having both the output of ts/typography/css/shorthand and expand.typography = true #128

Open C3PablO opened 1 year ago

C3PablO commented 1 year ago

What feature would you like?

The reason we want this is so we can still use the typography shorthand as we find it very handy but also use the css variable for letter spacing which value doesn't exist in the shorthand.

maybe an expand.keepOriginal option with the same signature (true|false|Filter )

Threat in Slack: https://tokens-studio.slack.com/archives/C0336AEQ06Q/p1684318079922049

Would you be available to contribute this feature?

jorenbroekema commented 1 year ago

Feel free to contribute :) let me know if you need help!

yringler commented 1 year ago

Here's a formatter in a gist which adds a letter spacing token after a typography font css shorthand token.

It would be really cool to expand the typography token into all of its tokens (as per the expand option), and have the short hand reference the expanded properties.

That would provide ultimate flexibility and themability. I'm not sure if we need that though, so I haven't implemented it.

--type-100-weight: 800;
--type-100-size: 10px;
--type-100-line-height: 16px;
--type-100-font-family: var(--font-nunito-sans);
--type-100: var(--type-100-weight) var(--type-100-size)/var(--type-100-line-height) var(--type-100-font-family);
yringler commented 1 year ago

Here's another gist. It moves the expansion logic into its own class, where it can be called from a custom formatter.

lukethacoder commented 1 year ago

just my 2 cents as I ran into a similar issue with [Object object] being printed for these typography fields.

This splits them into individual fields and removes the main token from the output (could be configured to keep if needed).

/**
 * Expands typography whilst keeping references to the original typography object.
 *
 * This formatter creates an individual token for each of the typography token keys.
 * For example:
 * --token-key-font-family: var(--global-reference-font-family);
 * --token-key-font-weight: var(--global-reference-font-weight);
 * --token-key-line-height: var(--global-reference-line-height);
 * --token-key-font-size: var(--global-reference-font-size);
 * --token-key-letter-spacing: var(--global-reference-letter-spacing);
 * --token-key-paragraph-spacing: var(--global-reference-paragraph-spacing);
 * --token-key-text-case: var(--global-reference-text-case);
 * --token-key-text-decoration: var(--global-reference-text-decoration);
 */
StyleDictionary.registerFormat({
  name: 'css/customFormatter',
  formatter: function ({ dictionary, platform, options, file }) {
    const { selector, outputReferences } = options

    const basePropertyFormatter = createPropertyFormatter({
      outputReferences,
      dictionary,
      format: 'css',
    })

    function propertyFormatter(token) {
      const cssVariables = []

      if (token.type === 'typography') {
        const values = {
          fontFamily: {
            name: 'font-family',
            value: getByTypographyKey(token, 'fontFamily'),
          },
          fontWeight: {
            name: 'font-weight',
            value: getByTypographyKey(token, 'fontWeight'),
          },
          lineHeight: {
            name: 'line-height',
            value: getByTypographyKey(token, 'lineHeight'),
          },
          fontSize: {
            name: 'font-size',
            value: getByTypographyKey(token, 'fontSize'),
          },
          letterSpacing: {
            name: 'letter-spacing',
            value: getByTypographyKey(token, 'letterSpacing'),
          },
          paragraphSpacing: {
            name: 'paragraph-spacing',
            value: getByTypographyKey(token, 'paragraphSpacing'),
          },
          textCase: {
            name: 'text-case',
            value: getByTypographyKey(token, 'textCase'),
          },
          textDecoration: {
            name: 'text-decoration',
            value: getByTypographyKey(token, 'textDecoration'),
          },
        }

        Object.keys(values).forEach((typographyKey) => {
          const data = values[typographyKey]
          if (data.value) {
            cssVariables.push(`  --${token.name}-${data.name}: ${data.value};`)
          }
        })
      } else {
        // run for 'typography' types too if you wish to keep the original token here
        cssVariables.push(basePropertyFormatter(token))
      }

      return cssVariables.join('\n')
    }

    return `${fileHeader({ file })}${selector} {
${dictionary.allTokens.map(propertyFormatter).join('\n')}
}`

    function getByTypographyKey(token, key) {
      const original = token.original
      let value = ''

      /*
       * A typography token can either be an object, which defines each part (font size, line height, etc).
       * Or it might be an alias (a string) which references another token.
       */

      // If it contains the data we need, use that data.
      if (typeof original.value == 'object') {
        value = getValue(token, key)
      } else {
        // If it's a reference, get the value that it's referring to.
        // Note that if outputReferences is true, instead of returning the source tokens value, it'll return a
        // var(--...) based on the source tokens name.
        if (!dictionary.usesReference(original.value)) {
          throw 'Typography string must be a reference?'
        }

        value = getValue(dictionary.getReferences(original.value)[0], key)
      }
      return value
    }

    function getValue(token, key) {
      if (!(key in token.original.value)) {
        return ''
      }

      const originalValueForKey = token.original.value[key]

      if (dictionary.usesReference(originalValueForKey)) {
        const source = dictionary.getReferences(originalValueForKey)[0]
        return options.outputReferences ? `var(--${source.name})` : source.value
      }

      return originalValueForKey
    }
  },
})
jorenbroekema commented 10 months ago

I did some more investigation and the short answer to this issue is that this is not at all trivial to do because we're mixing up the concept of a token group with a token.

Imagine this typography token:

{
  "foo": {
    "value": {
      "fontSize": "16px",
      "fontFamily": "Arial",
      "fontWeight": "Bold",
      "lineHeight": "1"
    },
    "type": "typography"
  }
}

After expanding this becomes:

{
  "foo": {
    "fontSize": {
      "value": "16px",
      "type": "fontSizes"
    },
    "fontFamily": {
      "value": "Arial",
      "type": "fontFamilies"
    },
    "fontWeight": {
      "value": "Bold",
      "type": "fontWeights"
    },
    "lineHeight": {
      "value": "1",
      "type": "lineHeights"
    }
  }
}

Now let's imagine we want to keep the original composite value as well, the structure would look something like:

{
  "foo": {
    "fontSize": {
      "value": "16px",
      "type": "fontSizes"
    },
    "fontFamily": {
      "value": "Arial",
      "type": "fontFamilies"
    },
    "fontWeight": {
      "value": "Bold",
      "type": "fontWeights"
    },
    "lineHeight": {
      "value": "1",
      "type": "lineHeights"
    }
  },
  "value": {
    "fontSize": "16px",
    "fontFamily": "Arial",
    "fontWeight": "Bold",
    "lineHeight": "1"
  },
  "type": "typography"
}

Making foo both a token group and a token (because it has a value and type property), that just doesn't work, it trips up Style-Dictionary and rightfully so.

A potential solution is to make the structure something like this:

{
  "foo": {
    "fontSize": {
      "value": "16px",
      "type": "fontSizes"
    },
    "fontFamily": {
      "value": "Arial",
      "type": "fontFamilies"
    },
    "fontWeight": {
      "value": "Bold",
      "type": "fontWeights"
    },
    "lineHeight": {
      "value": "1",
      "type": "lineHeights"
    },
    "__sd_keep_original__": {
      "value": {
        "fontSize": "16px",
        "fontFamily": "Arial",
        "fontWeight": "Bold",
        "lineHeight": "1"
      },
      "type": "typography"
    }
  }
}

Which would result in

:root {
  --foo-font-size: 16px;
  --foo-font-family: Arial;
  --foo-font-weight: 700;
  --foo-line-height: 1;
  --foo-sd-keep-original: 700 16px/1 Arial;
}

So the only thing that's left to do in sd-transforms is to register a name transform by default takes into account the casing option that you can pass to registerTransforms, which then filters out this sd_keep_original path item (because we don't want this in the output):

import { kebabCase, camelCase, capitalCase, snakeCase, constantCase } from 'change-case';

const casingMap = new Map([
  ['kebab', kebabCase],
  ['camel', camelCase],
  ['pascal', capitalCase],
  ['snake', snakeCase],
  ['constant', constantCase],
]);

StyleDictionary.registerTransform({
  type: "name",
  name: "ts/name",
  transformer: (token) => {
    return casingMap.get(casing)(token.path.filter(p => p !== '__sd_keep_original__').join('_'));
  }
});

Which then results in:

:root {
  --foo-font-size: 16px;
  --foo-font-family: Arial;
  --foo-font-weight: 700;
  --foo-line-height: 1;
  --foo: 700 16px/1 Arial;
}

Let me know if this makes sense, then I can go ahead and implement this when I get prio for it, or someone can speed it along by doing it themselves, hopefully it's clear enough from my explanation how to implement it, feel free to send me a message on slack if you need help

jorenbroekema commented 6 months ago

Marking as external since Style Dictionary has its own expand utils now for composite type tokens, so this feature should be added there.