├── .changeset ├── README.md └── config.json ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bugs.yml │ ├── config.yml │ └── features.yml └── workflows │ ├── release.yml │ └── verify.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.js ├── .prettierignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── TransformOptions.ts ├── checkAndEvaluateMath.ts ├── color-modifiers │ ├── darken.ts │ ├── lighten.ts │ ├── mix.ts │ ├── modifyColor.ts │ ├── transformColorModifiers.ts │ └── transparentize.ts ├── compose │ └── transformTypography.ts ├── css │ ├── transformHEXRGBa.ts │ ├── transformLetterSpacing.ts │ └── transformShadow.ts ├── index.ts ├── mapDescriptionToComment.ts ├── permutateThemes.ts ├── preprocessors │ ├── add-font-styles.ts │ ├── align-types.ts │ ├── exclude-parent-keys.ts │ └── parse-tokens.ts ├── register.ts ├── transformDimension.ts ├── transformFontWeight.ts ├── transformLineHeight.ts ├── transformOpacity.ts └── utils │ ├── constants.ts │ ├── is-nothing.ts │ └── percentageToDecimal.ts ├── test ├── integration │ ├── color-modifier-references.test.ts │ ├── cross-file-refs.test.ts │ ├── custom-group.test.ts │ ├── exclude-parent-keys.test.ts │ ├── expand-composition.test.ts │ ├── math-in-complex-values.test.ts │ ├── object-value-references.test.ts │ ├── output-references.test.ts │ ├── sd-transforms.test.ts │ ├── swift-UI-color.test.ts │ ├── tokens │ │ ├── color-modifier-references.tokens.json │ │ ├── cross-file-refs-1.tokens.json │ │ ├── cross-file-refs-2.tokens.json │ │ ├── cross-file-refs-3.tokens.json │ │ ├── custom-group.tokens.json │ │ ├── exclude-parent-keys.tokens.json │ │ ├── expand-composition.tokens.json │ │ ├── math-in-complex-values.tokens.json │ │ ├── object-value-references.tokens.json │ │ ├── output-references.tokens.json │ │ ├── sd-transforms.tokens.json │ │ ├── swift-UI-colors.tokens.json │ │ └── w3c-spec-compliance.tokens.json │ ├── utils.ts │ └── w3c-spec-compliance.test.ts ├── spec │ ├── checkAndEvaluateMath.spec.ts │ ├── color-modifiers │ │ └── transformColorModifiers.spec.ts │ ├── compose │ │ └── transformTypographyForCompose.spec.ts │ ├── css │ │ ├── transformHEXRGBa.spec.ts │ │ ├── transformLetterSpacing.spec.ts │ │ └── transformShadow.spec.ts │ ├── mapDescriptionToComment.spec.ts │ ├── permutateThemes.spec.ts │ ├── preprocessors │ │ ├── add-font-styles.spec.ts │ │ ├── align-types.spec.ts │ │ └── excludeParentKeys.spec.ts │ ├── register.spec.ts │ ├── transformDimension.spec.ts │ ├── transformFontWeights.spec.ts │ ├── transformLineHeight.spec.ts │ ├── transformOpacity.spec.ts │ └── utils │ │ └── percentageToDecimal.spec.ts └── suites │ └── transform-suite.spec.ts ├── tsconfig.json ├── vitest.config.js └── web-test-runner.config.mjs /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | /*.code-workspace 5 | /.history 6 | 7 | ## system files 8 | .DS_Store 9 | 10 | ## npm/yarn 11 | node_modules/ 12 | npm-debug.log 13 | 14 | ## temp folders 15 | /coverage/ 16 | 17 | dist/ -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'eslint-config-prettier', 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint', 'mocha'], 9 | rules: { 10 | 'no-console': ['error', { allow: ['warn', 'error'] }], 11 | 'mocha/no-skipped-tests': 'warn', 12 | 'mocha/no-exclusive-tests': 'error', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bugs.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | assignees: 6 | - jorenbroekema 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: What happened? 16 | description: A brief description of the bug 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: repro 21 | attributes: 22 | label: Reproduction 23 | description: Please tell us how we can reproduce this problem, preferably using https://configurator.tokens.studio/ 24 | - type: textarea 25 | id: expected 26 | attributes: 27 | label: Expected output 28 | description: What was the output you were expecting? 29 | - type: input 30 | id: version 31 | attributes: 32 | label: Version 33 | description: What version of sd-transforms are you running? 34 | placeholder: 0.5.5 35 | validations: 36 | required: true 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/features.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a feature 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: What feature would you like? 10 | description: A description of the feature you would like 11 | validations: 12 | required: true 13 | - type: checkboxes 14 | id: contribute 15 | attributes: 16 | label: Would you be available to contribute this feature? 17 | options: 18 | - label: Yes, I would like to contribute this 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | # Prevents changesets action from creating a PR on forks 11 | if: github.repository == 'tokens-studio/sd-transforms' 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@master 17 | with: 18 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 19 | fetch-depth: 0 20 | 21 | - name: Setup Node.js 20.x 22 | uses: actions/setup-node@master 23 | with: 24 | node-version: 20.x 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: Install Dependencies 28 | run: npm ci 29 | 30 | - name: Create Release Pull Request or Publish to npm 31 | id: changesets 32 | uses: changesets/action@v1 33 | with: 34 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 35 | publish: npm run release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify changes 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | verify: 7 | name: Verify changes 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [18.x, 20.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Setup Node ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | - name: Install Dependencies 21 | run: npm ci 22 | 23 | - name: Lint 24 | run: npm run lint 25 | 26 | - name: Install chromium 27 | run: npx playwright install --with-deps chromium 28 | 29 | - name: Unit tests 30 | run: npm run test:unit 31 | 32 | - name: Integration tests 33 | run: npm run test:integration 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | /*.code-workspace 5 | /.history 6 | 7 | ## system files 8 | .DS_Store 9 | 10 | ## npm/yarn 11 | node_modules/ 12 | npm-debug.log 13 | 14 | ## temp folders 15 | /coverage/ 16 | 17 | dist/ 18 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.ts': ['eslint --fix', 'prettier --write'], 3 | '*.md': ['prettier --write'], 4 | 'package.json': ['npx prettier-package-json --write package.json'], 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | /*.code-workspace 5 | /.history 6 | 7 | ## system files 8 | .DS_Store 9 | 10 | ## npm/yarn 11 | node_modules/ 12 | npm-debug.log 13 | 14 | ## temp folders 15 | /coverage/ 16 | 17 | dist/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Any contribution is greatly appreciated. If you feel like you lack knowledge/experience, even just creating a good issue with a minimal reproduction is already huge. 4 | If you can create a PR with a failing test showing the bug you found, even better, don't worry about putting the test in the perfect location but as a rule of thumb, if your bug is only reproducible in combination with Style Dictionary, put it in the `test/integration`, if it's more isolated to sd-transforms you can put it in `test/spec` folder. 5 | 6 | Is TypeScript not your strength and having issues? No problem, just do what you can and I'll help you fix whatever's broken in the PR. 7 | 8 | ## Tests 9 | 10 | Unit tests should provide 100% coverage. Use: 11 | 12 | ```sh 13 | npm run test 14 | npm run test:unit:coverage 15 | ``` 16 | 17 | To run the tests and view the coverage report, to see which things are untested. 18 | 19 | If some line of code really cannot be covered by a test or just doesn't make sense, [see here how to ignore them](https://modern-web.dev/docs/test-runner/writing-tests/code-coverage/#ignoring-uncovered-lines). 20 | 21 | > 100% may seem a bit crazy, but just know that it's a lot easier to retain 100% than to get it for the first time ;) 22 | > The biggest benefit to 100% is that it makes it very easy to identify redundant code; if it's redundant, it won't be covered. 23 | 24 | ## Linting 25 | 26 | This checks code quality with ESLint, formatting with Prettier and types with TypeScript. 27 | VSCode extensions for ESLint/Prettier are recommended, but you can always run: 28 | 29 | ```sh 30 | npm run format 31 | ``` 32 | 33 | after doing your work, to fix most issues automatically. 34 | 35 | ## Versioning 36 | 37 | We use [changesets](https://github.com/changesets/changesets) for versioning. If you are contributing something that warrants a new release of this library, run `npx changeset` and follow the CLI prompts and add a human readable explanation of your change. 38 | 39 | ## Contact 40 | 41 | For new ideas, feature requests, issues, bugs, etc., use GitHub issues. 42 | Also, feel free to reach out to us on [Slack](https://join.slack.com/t/tokens-studio/shared_invite/zt-1p8ea3m6t-C163oJcN9g3~YZTKRgo2hg). 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hyma BV 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tokens-studio/sd-transforms", 3 | "version": "1.3.0", 4 | "description": "Custom transforms for Style-Dictionary, to work with Design Tokens that are exported from Tokens Studio", 5 | "license": "MIT", 6 | "author": "Joren Broekema ", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/tokens-studio/sd-transforms.git" 10 | }, 11 | "type": "module", 12 | "exports": { 13 | ".": "./dist/index.js" 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "rimraf dist && tsc", 20 | "format": "npm run format:eslint && npm run format:prettier", 21 | "format:eslint": "eslint --ext .ts,.html . --fix", 22 | "format:prettier": "prettier \"**/*.{ts,md,mjs,js,cjs}\" \"package.json\" --write", 23 | "lint": "run-p lint:*", 24 | "lint:eslint": "eslint --ext .ts,.html .", 25 | "lint:prettier": "prettier \"**/*.ts\" --list-different || (echo '↑↑ these files are not prettier formatted ↑↑' && exit 1)", 26 | "lint:types": "tsc --noEmit", 27 | "prepare": "husky install", 28 | "release": "npm run build && changeset publish", 29 | "test": "npm run test:unit && npm run test:integration", 30 | "test:integration": "vitest run", 31 | "test:unit": "web-test-runner --coverage", 32 | "test:unit:coverage": "cd coverage/lcov-report && npx http-server -o -c-1", 33 | "test:unit:watch": "web-test-runner --watch" 34 | }, 35 | "dependencies": { 36 | "@bundled-es-modules/deepmerge": "^4.3.1", 37 | "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", 38 | "@tokens-studio/types": "^0.5.1", 39 | "colorjs.io": "^0.5.2", 40 | "expr-eval-fork": "^2.0.2", 41 | "is-mergeable-object": "^1.1.1" 42 | }, 43 | "peerDependencies": { 44 | "style-dictionary": "^4.3.0 || ^5.0.0-rc.0" 45 | }, 46 | "devDependencies": { 47 | "@changesets/cli": "^2.27.6", 48 | "@types/chai": "^4.3.16", 49 | "@types/mocha": "^10.0.7", 50 | "@types/tinycolor2": "^1.4.6", 51 | "@typescript-eslint/eslint-plugin": "^5.54.0", 52 | "@typescript-eslint/parser": "^5.54.0", 53 | "@web/dev-server-esbuild": "^1.0.4", 54 | "@web/test-runner": "^0.18.2", 55 | "@web/test-runner-playwright": "^0.11.0", 56 | "chai": "^5.1.1", 57 | "eslint": "^8.32.0", 58 | "eslint-config-prettier": "^8.6.0", 59 | "eslint-plugin-mocha": "^10.4.3", 60 | "hanbi": "^1.0.3", 61 | "http-server": "^14.1.1", 62 | "husky": "^8.0.0", 63 | "lint-staged": "^15.2.10", 64 | "npm-run-all": "^4.1.5", 65 | "prettier": "^3.3.2", 66 | "prettier-package-json": "^2.8.0", 67 | "rimraf": "^6.0.1", 68 | "tinycolor2": "^1.6.0", 69 | "typescript": "^5.8.2", 70 | "vitest": "^3.0.9" 71 | }, 72 | "keywords": [ 73 | "design tokens", 74 | "figma", 75 | "style-dictionary" 76 | ], 77 | "engines": { 78 | "node": ">=18.0.0" 79 | }, 80 | "prettier": { 81 | "printWidth": 100, 82 | "singleQuote": true, 83 | "arrowParens": "avoid", 84 | "trailingComma": "all" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/TransformOptions.ts: -------------------------------------------------------------------------------- 1 | export type ColorModifierFormat = 'hex' | 'hsl' | 'lch' | 'p3' | 'srgb'; 2 | 3 | export interface ColorModifierOptions { 4 | format: ColorModifierFormat; 5 | } 6 | 7 | export interface TransformOptions { 8 | platform?: 'css' | 'compose'; 9 | name?: string; 10 | withSDBuiltins?: boolean; 11 | alwaysAddFontStyle?: boolean; 12 | excludeParentKeys?: boolean; 13 | ['ts/color/modifiers']?: ColorModifierOptions; 14 | } 15 | -------------------------------------------------------------------------------- /src/checkAndEvaluateMath.ts: -------------------------------------------------------------------------------- 1 | import { DesignToken } from 'style-dictionary/types'; 2 | import { Parser } from 'expr-eval-fork'; 3 | import { parse, reduceExpression } from '@bundled-es-modules/postcss-calc-ast-parser'; 4 | import { defaultFractionDigits } from './utils/constants.js'; 5 | 6 | const mathChars = ['+', '-', '*', '/']; 7 | 8 | const parser = new Parser(); 9 | 10 | function checkIfInsideGroup(expr: string, fullExpr: string): boolean { 11 | // make sure all regex-specific characters are escaped by backslashes 12 | const exprEscaped = expr.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); 13 | // Reg which checks whether the sub expression is fitted inside of a group ( ) in the full expression 14 | const reg = new RegExp(`\\(.*?${exprEscaped}.*?\\)`, 'g'); 15 | return !!fullExpr.match(reg) || !!expr.match(/\(/g); // <-- latter is needed because an expr piece might be including the opening '(' character 16 | } 17 | 18 | /** 19 | * Checks expressions like: 8 / 4 * 7px 8 * 5px 2 * 4px 20 | * and divides them into 3 single values: 21 | * ['8 / 4 * 7px', '8 * 5px', '2 * 4px'] 22 | * 23 | * It splits everything by " " spaces, then checks in which places 24 | * there is a space but with no math operater left or right of it, 25 | * then determines this must mean it's a multi-value separator 26 | */ 27 | function splitMultiIntoSingleValues(expr: string): string[] { 28 | const tokens = expr.split(' '); 29 | // indexes in the string at which a space separator exists that is a multi-value space separator 30 | const indexes = [] as number[]; 31 | let skipNextIteration = false; 32 | tokens.forEach((tok, i) => { 33 | const left = i > 0 ? tokens[i - 1] : ''; 34 | const right = tokens[i + 1] ?? ''; 35 | 36 | // conditions under which math expr is valid 37 | const conditions = [ 38 | mathChars.includes(tok), // current token is a math char 39 | mathChars.includes(right) && mathChars.includes(left), // left/right are both math chars 40 | left === '' && mathChars.includes(right), // tail of expr, right is math char 41 | right === '' && mathChars.includes(left), // head of expr, left is math char 42 | tokens.length <= 1, // expr is valid if it's a simple 1 token expression 43 | Boolean(tok.match(/\)$/) && mathChars.includes(right)), // end of group ), right is math char 44 | checkIfInsideGroup(tok, expr), // exprs that aren't math expressions are okay within ( ) groups 45 | ]; 46 | 47 | if (conditions.every(cond => !cond)) { 48 | if (!skipNextIteration) { 49 | indexes.push(i); 50 | // if the current token itself does not also contain a math character 51 | // make sure we skip the next iteration, because otherwise the conditions 52 | // will be all false again for the next char which is essentially a "duplicate" hit 53 | // meaning we would unnecessarily push another index to split our multi-value by 54 | if (!mathChars.find(char => tok.includes(char))) { 55 | skipNextIteration = true; 56 | } 57 | } else { 58 | skipNextIteration = false; 59 | } 60 | } 61 | }); 62 | if (indexes.length > 0) { 63 | indexes.push(tokens.length); 64 | const exprArr = [] as string[]; 65 | let currIndex = 0; 66 | indexes.forEach(i => { 67 | const singleValue = tokens.slice(currIndex, i + 1).join(' '); 68 | if (singleValue) { 69 | exprArr.push(singleValue); 70 | } 71 | currIndex = i + 1; 72 | }); 73 | return exprArr; 74 | } 75 | return [expr]; 76 | } 77 | 78 | export function parseAndReduce( 79 | expr: string, 80 | fractionDigits = defaultFractionDigits, 81 | ): string | number { 82 | let result: string | number = expr; 83 | 84 | // Check if expression is already a number 85 | if (!isNaN(Number(result))) { 86 | return result; 87 | } 88 | 89 | // We check for px unit, then remove it, since these are essentially numbers in tokens context 90 | // We remember that we had px in there so we can put it back in the end result 91 | const hasPx = expr.match('px'); 92 | const noPixExpr = expr.replace(/px/g, ''); 93 | const unitRegex = /(\d+\.?\d*)(?([a-zA-Z]|%)+)/g; 94 | 95 | let matchArr; 96 | const foundUnits: Set = new Set(); 97 | while ((matchArr = unitRegex.exec(noPixExpr)) !== null) { 98 | if (matchArr?.groups) { 99 | foundUnits.add(matchArr.groups.unit); 100 | } 101 | } 102 | // multiple units (besides px) found, cannot parse the expression 103 | if (foundUnits.size > 1) { 104 | return result; 105 | } 106 | const resultUnit = Array.from(foundUnits)[0] ?? (hasPx ? 'px' : ''); 107 | 108 | if (!isNaN(Number(noPixExpr))) { 109 | result = Number(noPixExpr); 110 | } 111 | 112 | if (typeof result !== 'number') { 113 | // Try to evaluate as expr-eval expression 114 | let evaluated; 115 | try { 116 | evaluated = parser.evaluate(`${noPixExpr}`); 117 | if (typeof evaluated === 'number') { 118 | result = evaluated; 119 | } 120 | } catch (ex) { 121 | // no-op 122 | } 123 | } 124 | 125 | if (typeof result !== 'number') { 126 | let exprToParse = noPixExpr; 127 | // math operators, excluding * 128 | // (** or ^ exponent would theoretically be fine, but postcss-calc-ast-parser does not support it 129 | const operatorsRegex = /[/+%-]/g; 130 | // if we only have * operator, we can consider expression as unitless and compute it that way 131 | // we already know we dont have mixed units from (foundUnits.size > 1) guard above 132 | if (!exprToParse.match(operatorsRegex)) { 133 | exprToParse = exprToParse.replace(new RegExp(resultUnit, 'g'), ''); 134 | } 135 | // Try to evaluate as postcss-calc-ast-parser expression 136 | const calcParsed = parse(exprToParse, { allowInlineCommnets: false }); 137 | 138 | // Attempt to reduce the math expression 139 | const reduced = reduceExpression(calcParsed); 140 | // E.g. if type is Length, like 4 * 7rem would be 28rem 141 | if (reduced && !isNaN(reduced.value)) { 142 | result = reduced.value; 143 | } 144 | } 145 | 146 | if (typeof result !== 'number') { 147 | // parsing failed, return the original expression 148 | return result; 149 | } 150 | 151 | // the outer Number() gets rid of insignificant trailing zeros of decimal numbers 152 | const reducedToFixed = Number(Number.parseFloat(`${result}`).toFixed(fractionDigits)); 153 | result = resultUnit ? `${reducedToFixed}${resultUnit}` : reducedToFixed; 154 | return result; 155 | } 156 | 157 | export function checkAndEvaluateMath( 158 | token: DesignToken, 159 | fractionDigits?: number, 160 | ): DesignToken['value'] { 161 | const expr = token.$value ?? token.value; 162 | const type = token.$type ?? token.type; 163 | 164 | if (!['string', 'object'].includes(typeof expr)) { 165 | return expr; 166 | } 167 | 168 | const resolveMath = (expr: number | string) => { 169 | if (typeof expr !== 'string') { 170 | return expr; 171 | } 172 | const exprs = splitMultiIntoSingleValues(expr); 173 | const reducedExprs = exprs.map(_expr => parseAndReduce(_expr, fractionDigits)); 174 | if (reducedExprs.length === 1) { 175 | return reducedExprs[0]; 176 | } 177 | return reducedExprs.join(' '); 178 | }; 179 | 180 | const transformProp = (val: Record, prop: string) => { 181 | if (typeof val === 'object' && val[prop] !== undefined) { 182 | val[prop] = resolveMath(val[prop]); 183 | } 184 | return val; 185 | }; 186 | 187 | let transformed = expr; 188 | switch (type) { 189 | case 'typography': 190 | case 'border': { 191 | transformed = transformed as Record; 192 | // double check that expr is still an object and not already shorthand transformed to a string 193 | if (typeof expr === 'object') { 194 | Object.keys(transformed).forEach(prop => { 195 | transformed = transformProp(transformed, prop); 196 | }); 197 | } 198 | break; 199 | } 200 | case 'shadow': { 201 | transformed = transformed as 202 | | Record 203 | | Record[]; 204 | const transformShadow = (shadowVal: Record) => { 205 | // double check that expr is still an object and not already shorthand transformed to a string 206 | if (typeof expr === 'object') { 207 | Object.keys(shadowVal).forEach(prop => { 208 | shadowVal = transformProp(shadowVal, prop); 209 | }); 210 | } 211 | return shadowVal; 212 | }; 213 | if (Array.isArray(transformed)) { 214 | transformed = transformed.map(transformShadow); 215 | } 216 | transformed = transformShadow(transformed); 217 | break; 218 | } 219 | default: 220 | transformed = resolveMath(transformed); 221 | } 222 | 223 | return transformed; 224 | } 225 | -------------------------------------------------------------------------------- /src/color-modifiers/darken.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | import { ColorSpaceTypes } from '@tokens-studio/types'; 3 | 4 | export function darken(color: Color, colorSpace: ColorSpaceTypes, amount: number): Color { 5 | switch (colorSpace) { 6 | case 'lch': { 7 | const lightness = color.lch.l; 8 | const difference = lightness; 9 | const newChroma = Math.max(0, color.lch.c - amount * color.lch.c); 10 | const newLightness = Math.max(0, lightness - difference * amount); 11 | color.set('lch.l', newLightness); 12 | color.set('lch.c', newChroma); 13 | return color; 14 | } 15 | case 'hsl': { 16 | const lightness = color.hsl.l; 17 | const difference = lightness; 18 | const newLightness = Math.max(0, lightness - difference * amount); 19 | color.set('hsl.l', newLightness); 20 | return color; 21 | } 22 | case 'p3': { 23 | const colorInP3 = color.to('p3'); 24 | const newRed = Math.max(0, colorInP3.p3.r - amount * colorInP3.p3.r); 25 | const newGreen = Math.max(0, colorInP3.p3.g - amount * colorInP3.p3.g); 26 | const newBlue = Math.max(0, colorInP3.p3.b - amount * colorInP3.p3.b); 27 | colorInP3.set('p3.r', newRed); 28 | colorInP3.set('p3.g', newGreen); 29 | colorInP3.set('p3.b', newBlue); 30 | return colorInP3; 31 | } 32 | 33 | case 'srgb': { 34 | const newRed = Math.max(0, color.srgb.r - amount * color.srgb.r); 35 | const newGreen = Math.max(0, color.srgb.g - amount * color.srgb.g); 36 | const newBlue = Math.max(0, color.srgb.b - amount * color.srgb.b); 37 | color.set('srgb.r', newRed); 38 | color.set('srgb.g', newGreen); 39 | color.set('srgb.b', newBlue); 40 | return color; 41 | } 42 | 43 | default: { 44 | return color.darken(amount) as Color; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/color-modifiers/lighten.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | import { ColorSpaceTypes } from '@tokens-studio/types'; 3 | 4 | export function lighten(color: Color, colorSpace: ColorSpaceTypes, amount: number): Color { 5 | switch (colorSpace) { 6 | case 'lch': { 7 | const lightness = color.lch.l; 8 | const difference = 100 - lightness; 9 | const newChroma = Math.max(0, color.lch.c - amount * color.lch.c); 10 | const newLightness = Math.min(100, lightness + difference * amount); 11 | color.set('lch.l', newLightness); 12 | color.set('lch.c', newChroma); 13 | return color; 14 | } 15 | case 'hsl': { 16 | const lightness = color.hsl.l; 17 | const difference = 100 - lightness; 18 | const newLightness = Math.min(100, lightness + difference * amount); 19 | color.set('hsl.l', newLightness); 20 | return color; 21 | } 22 | case 'p3': { 23 | const colorInP3 = color.to('p3'); 24 | const newRed = Math.min(1, colorInP3.p3.r + amount * (1 - colorInP3.p3.r)); 25 | const newGreen = Math.min(1, colorInP3.p3.g + amount * (1 - colorInP3.p3.g)); 26 | const newBlue = Math.min(1, colorInP3.p3.b + amount * (1 - colorInP3.p3.b)); 27 | colorInP3.set('p3.r', newRed); 28 | colorInP3.set('p3.g', newGreen); 29 | colorInP3.set('p3.b', newBlue); 30 | return colorInP3; 31 | } 32 | case 'srgb': { 33 | const newRed = Math.min(1, color.srgb.r + amount * (1 - color.srgb.r)); 34 | const newGreen = Math.min(1, color.srgb.g + amount * (1 - color.srgb.g)); 35 | const newBlue = Math.min(1, color.srgb.b + amount * (1 - color.srgb.b)); 36 | color.set('srgb.r', newRed); 37 | color.set('srgb.g', newGreen); 38 | color.set('srgb.b', newBlue); 39 | return color; 40 | } 41 | default: { 42 | return color.lighten(amount) as Color; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/color-modifiers/mix.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | import { ColorSpaceTypes } from '@tokens-studio/types'; 3 | import { defaultColorPrecision } from '../utils/constants.js'; 4 | 5 | export function mix( 6 | color: Color, 7 | colorSpace: ColorSpaceTypes, 8 | amount: number, 9 | mixColor: Color, 10 | ): Color { 11 | const mixValue = Math.max(0, Math.min(1, Number(amount))); 12 | 13 | return new Color( 14 | color 15 | .mix(mixColor, mixValue, { space: colorSpace }) 16 | .toString({ precision: defaultColorPrecision }), 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/color-modifiers/modifyColor.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | import { transparentize } from './transparentize.js'; 3 | import { mix } from './mix.js'; 4 | import { darken } from './darken.js'; 5 | import { lighten } from './lighten.js'; 6 | import { ColorModifier } from '@tokens-studio/types'; 7 | import { defaultColorPrecision, defaultFractionDigits } from '../utils/constants.js'; 8 | import { parseAndReduce } from '../checkAndEvaluateMath.js'; 9 | 10 | // Users using UIColor swift format are blocked from using such transform in 11 | // combination with this color modify transform when using references. 12 | // This is because reference value props are deferred so the UIColor 13 | // transform always applies first to non-reference tokens, and only after that 14 | // can the color modifier transitive transform apply to deferred tokens, at which point 15 | // it is already UIColor format. 16 | // We can remove this hotfix later once https://github.com/amzn/style-dictionary/issues/1063 17 | // is resolved. Then users can use a post-transitive transform for more fine grained control 18 | function parseUIColor(value: string): string { 19 | const reg = new RegExp( 20 | `UIColor\\(red: (?[\\d\\.]+?), green: (?[\\d\\.]+?), blue: (?[\\d\\.]+?), alpha: (?[\\d\\.]+?)\\)`, 21 | ); 22 | const match = value.match(reg); 23 | if (match?.groups) { 24 | const { red, green, blue, alpha } = match.groups; 25 | return `rgba(${parseFloat(red) * 255}, ${parseFloat(green) * 255}, ${ 26 | parseFloat(blue) * 255 27 | }, ${alpha})`; 28 | } 29 | return value; 30 | } 31 | 32 | export function modifyColor( 33 | baseColor: string | undefined, 34 | modifier: ColorModifier, 35 | ): string | undefined { 36 | if (baseColor === undefined) { 37 | return baseColor; 38 | } 39 | 40 | baseColor = parseUIColor(baseColor); 41 | 42 | const color = new Color(baseColor); 43 | let returnedColor = color; 44 | const modifyValueResolvedCalc = Number(parseAndReduce(modifier.value, defaultFractionDigits)); 45 | try { 46 | switch (modifier.type) { 47 | case 'lighten': 48 | returnedColor = lighten(color, modifier.space, modifyValueResolvedCalc); 49 | break; 50 | case 'darken': 51 | returnedColor = darken(color, modifier.space, modifyValueResolvedCalc); 52 | break; 53 | case 'mix': 54 | returnedColor = mix( 55 | color, 56 | modifier.space, 57 | modifyValueResolvedCalc, 58 | new Color(modifier.color), 59 | ); 60 | break; 61 | case 'alpha': { 62 | returnedColor = transparentize(color, modifyValueResolvedCalc); 63 | break; 64 | } 65 | default: 66 | returnedColor = color; 67 | break; 68 | } 69 | 70 | returnedColor = returnedColor.to(modifier.space); 71 | 72 | if (modifier.format && ['lch', 'srgb', 'p3', 'hsl', 'hex'].includes(modifier.format)) { 73 | // Since hex is not a color space, convert to srgb, toString will then be able to format to hex 74 | if (modifier.format === 'hex') { 75 | returnedColor = returnedColor.to('srgb'); 76 | } else { 77 | returnedColor = returnedColor.to(modifier.format); 78 | } 79 | } 80 | 81 | return returnedColor.toString({ 82 | inGamut: true, 83 | precision: defaultColorPrecision, 84 | format: modifier.format, 85 | }); 86 | } catch (e) { 87 | return baseColor; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/color-modifiers/transformColorModifiers.ts: -------------------------------------------------------------------------------- 1 | import type { DesignToken } from 'style-dictionary/types'; 2 | import { usesReferences } from 'style-dictionary/utils'; 3 | import { modifyColor } from './modifyColor.js'; 4 | import { ColorModifier } from '@tokens-studio/types'; 5 | import { ColorModifierOptions } from '../TransformOptions.js'; 6 | /** 7 | * Helper: Transforms color tokens with tokens studio color modifiers 8 | */ 9 | export function transformColorModifiers( 10 | token: DesignToken, 11 | options?: ColorModifierOptions, 12 | ): string | undefined { 13 | const modifier = token.$extensions['studio.tokens']?.modify as ColorModifier; 14 | 15 | // If some of the modifier props contain references or the modifier itself is a reference 16 | // we should return undefined to manually defer this transformation until the references are resolved 17 | // see: https://github.com/amzn/style-dictionary/blob/v4/docs/transforms.md#defer-transitive-transformation-manually 18 | if (usesReferences(modifier) || Object.values(modifier).some(prop => usesReferences(prop))) { 19 | return undefined; 20 | } 21 | 22 | if (options?.format) { 23 | modifier.format = options.format; 24 | } 25 | return modifyColor(token.$value ?? token.value, modifier); 26 | } 27 | -------------------------------------------------------------------------------- /src/color-modifiers/transparentize.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | 3 | export function transparentize(color: Color, amount: number): Color { 4 | const _color = color; 5 | _color.alpha = Math.max(0, Math.min(1, Number(amount))); 6 | return _color; 7 | } 8 | -------------------------------------------------------------------------------- /src/compose/transformTypography.ts: -------------------------------------------------------------------------------- 1 | import { DesignToken } from 'style-dictionary/types'; 2 | import { transformFontWeight } from '../transformFontWeight.js'; 3 | 4 | /** 5 | * Helper: Transforms typography object to typography shorthand for Jetpack Compose 6 | */ 7 | export function transformTypographyForCompose(token: DesignToken): DesignToken['value'] { 8 | const val = token.$value ?? token.value; 9 | if (val === undefined) return undefined; 10 | 11 | /** 12 | * Mapping between https://docs.tokens.studio/available-tokens/typography-tokens 13 | * and https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextStyle 14 | * Unsupported property: 15 | * - paragraphSpacing 16 | */ 17 | const textStylePropertiesMapping = { 18 | fontFamily: 'fontFamily', 19 | fontWeight: 'fontWeight', 20 | lineHeight: 'lineHeight', 21 | fontSize: 'fontSize', 22 | letterSpacing: 'letterSpacing', 23 | paragraphIndent: 'textIndent', 24 | }; 25 | 26 | /** 27 | * Constructs a `TextStyle`, e.g. 28 | * TextStyle( 29 | * fontSize = 16.dp 30 | * ) 31 | */ 32 | return `${Object.entries(val).reduce( 33 | (acc, [propName, v]) => 34 | `${acc}${ 35 | textStylePropertiesMapping[propName as keyof typeof textStylePropertiesMapping] 36 | ? `${ 37 | propName === 'fontWeight' 38 | ? transformFontWeight({ 39 | value: v, 40 | }) 41 | : v 42 | }\n` 43 | : '' 44 | }`, 45 | 'TextStyle(\n', 46 | )})`; 47 | } 48 | -------------------------------------------------------------------------------- /src/css/transformHEXRGBa.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | import { DesignToken } from 'style-dictionary/types'; 3 | 4 | /** 5 | * Helper: Transforms hex rgba colors used in figma tokens: 6 | * rgba(#ffffff, 0.5) =? rgba(255, 255, 255, 0.5). 7 | * This is kind of like an alpha() function. 8 | */ 9 | export function transformHEXRGBaForCSS(token: DesignToken): DesignToken['value'] { 10 | const val = token.$value ?? token.value; 11 | const type = token.$type ?? token.type; 12 | if (val === undefined) return undefined; 13 | 14 | const transformHEXRGBa = (val: string) => { 15 | const regex = /rgba\(\s*(?#.+?)\s*,\s*(?\d*(\.\d*|%)*)\s*\)/g; 16 | return val.replace(regex, (match, hex, alpha) => { 17 | try { 18 | const [r, g, b] = new Color(hex).srgb; 19 | return `rgba(${r * 255}, ${g * 255}, ${b * 255}, ${alpha})`; 20 | } catch (e) { 21 | console.warn(`Tried parsing "${hex}" as a hex value, but failed.`); 22 | return match; 23 | } 24 | }); 25 | }; 26 | 27 | const transformProp = (val: Record, prop: string) => { 28 | if (val[prop] !== undefined && typeof val[prop] === 'string') { 29 | val[prop] = transformHEXRGBa(val[prop]); 30 | } 31 | return val; 32 | }; 33 | 34 | let transformed = val; 35 | 36 | switch (type) { 37 | case 'border': 38 | case 'shadow': { 39 | transformed = transformed as 40 | | Record 41 | | Record[]; 42 | if (Array.isArray(transformed)) { 43 | transformed = transformed.map(item => transformProp(item, 'color')); 44 | } else { 45 | transformed = transformProp(transformed, 'color'); 46 | } 47 | break; 48 | } 49 | default: 50 | transformed = transformHEXRGBa(val); 51 | } 52 | 53 | return transformed; 54 | } 55 | -------------------------------------------------------------------------------- /src/css/transformLetterSpacing.ts: -------------------------------------------------------------------------------- 1 | import { DesignToken } from 'style-dictionary/types'; 2 | import { percentageToDecimal } from '../utils/percentageToDecimal.js'; 3 | 4 | /** 5 | * Helper: Transforms letter spacing % to em 6 | */ 7 | export function transformLetterSpacingForCSS(token: DesignToken): DesignToken['value'] { 8 | const val = token.$value ?? token.value; 9 | const type = token.$type ?? token.type; 10 | if (val === undefined) return undefined; 11 | 12 | const transformLetterSpacing = (letterspacing: string | number) => { 13 | const decimal = percentageToDecimal(letterspacing); 14 | return typeof decimal === 'string' || isNaN(decimal) ? `${letterspacing}` : `${decimal}em`; 15 | }; 16 | 17 | if (type === 'typography') { 18 | if (val.letterSpacing !== undefined) { 19 | return { 20 | ...val, 21 | letterSpacing: transformLetterSpacing(val.letterSpacing), 22 | }; 23 | } 24 | return val; 25 | } 26 | return transformLetterSpacing(val); 27 | } 28 | -------------------------------------------------------------------------------- /src/css/transformShadow.ts: -------------------------------------------------------------------------------- 1 | import { SingleBoxShadowToken, BoxShadowTypes } from '@tokens-studio/types'; 2 | 3 | export function transformShadow(value: SingleBoxShadowToken['value']) { 4 | const alignShadowType = (val: string) => { 5 | return val === 'innerShadow' || val === 'inset' ? 'inset' : undefined; 6 | }; 7 | 8 | if (Array.isArray(value)) { 9 | return value.map(v => ({ 10 | ...v, 11 | type: alignShadowType(v.type), 12 | })); 13 | } 14 | 15 | if (typeof value === 'object') { 16 | value.type = alignShadowType(value.type) as BoxShadowTypes; 17 | } 18 | return value; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { register, getTransforms } from './register.js'; 2 | 3 | export { addFontStyles } from './preprocessors/add-font-styles.js'; 4 | export { alignTypes } from './preprocessors/align-types.js'; 5 | export { excludeParentKeys } from './preprocessors/exclude-parent-keys.js'; 6 | export { parseTokens } from './preprocessors/parse-tokens.js'; 7 | 8 | export { mapDescriptionToComment } from './mapDescriptionToComment.js'; 9 | export { checkAndEvaluateMath } from './checkAndEvaluateMath.js'; 10 | export { transformDimension } from './transformDimension.js'; 11 | export { transformFontWeight } from './transformFontWeight.js'; 12 | export { transformColorModifiers } from './color-modifiers/transformColorModifiers.js'; 13 | export { transformLineHeight } from './transformLineHeight.js'; 14 | export type { TransformOptions } from './TransformOptions.js'; 15 | export { transformOpacity } from './transformOpacity.js'; 16 | 17 | export { transformHEXRGBaForCSS } from './css/transformHEXRGBa.js'; 18 | export { transformLetterSpacingForCSS } from './css/transformLetterSpacing.js'; 19 | export { transformShadow } from './css/transformShadow.js'; 20 | 21 | export { transformTypographyForCompose } from './compose/transformTypography.js'; 22 | 23 | export { permutateThemes } from './permutateThemes.js'; 24 | 25 | /** 26 | * Some of the Tokens Studio typography/shadow props need to be aligned 27 | * when expanding them through StyleDictionary expand 28 | */ 29 | export const expandTypesMap = { 30 | typography: { 31 | paragraphSpacing: 'dimension', 32 | paragraphIndent: 'dimension', 33 | // for types "textDecoration", "textCase", "fontWeight", "lineHeight", we should keep the type as is 34 | // because no DTCG type that currently exists provides a good match. 35 | // for fontWeight: recognized fontWeight keys (e.g. "regular") 36 | // for lineHeight: lineHeights can be both dimension or number 37 | 38 | // overrides https://github.com/amzn/style-dictionary/blob/main/lib/utils/expandObjectTokens.js#L54-L55 39 | // so that our lineHeight and letterSpacing transforms can still apply 40 | lineHeight: 'lineHeight', 41 | 42 | // this one can be removed once we can rely on preExpand meta: 43 | // https://github.com/amzn/style-dictionary/pull/1269 44 | letterSpacing: 'letterSpacing', 45 | }, 46 | composition: { 47 | boxShadow: 'shadow', 48 | spacing: 'dimension', 49 | sizing: 'dimension', 50 | borderRadius: 'dimension', 51 | borderWidth: 'dimension', 52 | paragraphSpacing: 'dimension', 53 | paragraphIndent: 'dimension', 54 | text: 'content', 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/mapDescriptionToComment.ts: -------------------------------------------------------------------------------- 1 | import type { DesignToken } from 'style-dictionary/types'; 2 | 3 | /** 4 | * Helper: Maps the token description to a style dictionary comment attribute - this will be picked up by some Style Dictionary 5 | * formats and automatically output as code comments 6 | */ 7 | export function mapDescriptionToComment(token: DesignToken): DesignToken { 8 | // intentional mutation of the original object 9 | const _t = token; 10 | // Replace carriage returns with just newlines 11 | _t.comment = _t.description.replace(/\r?\n|\r/g, '\n'); 12 | return _t; 13 | } 14 | -------------------------------------------------------------------------------- /src/permutateThemes.ts: -------------------------------------------------------------------------------- 1 | import { ThemeObject, TokenSetStatus } from '@tokens-studio/types'; 2 | 3 | declare interface Options { 4 | separator?: string; 5 | } 6 | 7 | function mapThemesToSetsObject(themes: ThemeObject[]) { 8 | return Object.fromEntries( 9 | themes.map(theme => [theme.name, filterTokenSets(theme.selectedTokenSets)]), 10 | ); 11 | } 12 | 13 | export function permutateThemes(themes: ThemeObject[], { separator = '-' } = {} as Options) { 14 | if (themes.some(theme => theme.group)) { 15 | // Sort themes by groups 16 | const groups: Record = {}; 17 | themes.forEach(theme => { 18 | if (theme.group) { 19 | groups[theme.group] = [...(groups[theme.group] ?? []), theme]; 20 | } else { 21 | throw new Error( 22 | `Theme ${theme.name} does not have a group property, which is required for multi-dimensional theming.`, 23 | ); 24 | } 25 | }); 26 | 27 | if (Object.keys(groups).length <= 1) { 28 | return mapThemesToSetsObject(themes); 29 | } 30 | 31 | // Create theme permutations 32 | const permutations = cartesian(Object.values(groups)) as Array; 33 | 34 | return Object.fromEntries( 35 | permutations.map(perm => { 36 | // 1) concat the names of the theme groups to create the permutation theme name 37 | // 2) merge the selectedTokenSets together from the different theme group parts 38 | const reduced = perm.reduce( 39 | (acc, curr) => [ 40 | `${acc[0]}${acc[0] ? separator : ''}${curr.name}`, 41 | [...acc[1], ...filterTokenSets(curr.selectedTokenSets)], 42 | ], 43 | ['', [] as string[]], 44 | ); 45 | 46 | // Dedupe the tokensets, return as entries [name, sets] 47 | return [reduced[0], [...new Set(reduced[1])]]; 48 | }), 49 | ); 50 | } else { 51 | return mapThemesToSetsObject(themes); 52 | } 53 | } 54 | 55 | function filterTokenSets(tokensets: Record) { 56 | return ( 57 | Object.entries(tokensets) 58 | .filter(([, val]) => val !== 'disabled') 59 | // ensure source type sets are always ordered before enabled type sets 60 | .sort((a, b) => { 61 | if (a[1] === 'source' && b[1] === 'enabled') { 62 | return -1; 63 | } else if (a[1] === 'enabled' && b[1] === 'source') { 64 | return 1; 65 | } 66 | return 0; 67 | }) 68 | .map(entry => entry[0]) 69 | ); 70 | } 71 | 72 | // cartesian permutations: [[1,2], [3,4]] -> [[1,3], [1,4], [2,3], [2,4]] 73 | function cartesian(a: Array) { 74 | return a.reduce((a, b) => a.flatMap(d => b.map(e => [d, e].flat()))); 75 | } 76 | -------------------------------------------------------------------------------- /src/preprocessors/add-font-styles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeepKeyTokenMap, 3 | TokenTypographyValue, 4 | SingleToken, 5 | SingleFontWeightsToken, 6 | } from '@tokens-studio/types'; 7 | import { usesReferences, resolveReferences } from 'style-dictionary/utils'; 8 | import { fontWeightReg, fontStyles } from '../transformFontWeight.js'; 9 | import { TransformOptions } from '../TransformOptions.js'; 10 | 11 | function resolveFontWeight(fontWeight: string, copy: DeepKeyTokenMap, usesDtcg: boolean) { 12 | let resolved = fontWeight; 13 | if (usesReferences(fontWeight)) { 14 | try { 15 | resolved = `${resolveReferences(fontWeight, copy, { usesDtcg })}`; 16 | } catch (e) { 17 | // dont throw fatal, see: https://github.com/tokens-studio/sd-transforms/issues/217 18 | // we throw once we only support SD v4, for v3 we need to be more permissive 19 | console.error(e); 20 | } 21 | } 22 | return resolved; 23 | } 24 | 25 | function splitWeightStyle(fontWeight: string, alwaysAddFontStyle: boolean) { 26 | let weight = fontWeight; 27 | let style = alwaysAddFontStyle ? 'normal' : undefined; 28 | if (fontWeight) { 29 | const fontStyleMatch = fontWeight.match(fontWeightReg); 30 | if (fontStyleMatch?.groups?.weight && fontStyleMatch.groups.style) { 31 | style = fontStyleMatch.groups.style.toLowerCase(); 32 | weight = fontStyleMatch?.groups?.weight; 33 | } 34 | 35 | // Roboto Regular Italic might have only: `fontWeight: 'Italic'` 36 | // which means that the weight is Regular and the style is Italic 37 | if (fontStyles.includes(fontWeight.toLowerCase())) { 38 | style = fontWeight.toLowerCase(); 39 | weight = 'Regular'; 40 | } 41 | } 42 | return { weight, style }; 43 | } 44 | 45 | function recurse( 46 | slice: DeepKeyTokenMap | SingleToken, 47 | refCopy: DeepKeyTokenMap, 48 | alwaysAddFontStyle = false, 49 | ) { 50 | (Object.keys(slice) as (keyof typeof slice)[]).forEach(key => { 51 | const potentiallyToken = slice[key]; 52 | const isToken = 53 | typeof potentiallyToken === 'object' && 54 | ((Object.hasOwn(potentiallyToken, '$type') && Object.hasOwn(potentiallyToken, '$value')) || 55 | (Object.hasOwn(potentiallyToken, 'type') && Object.hasOwn(potentiallyToken, 'value'))); 56 | 57 | if (isToken) { 58 | const token = potentiallyToken as SingleToken; 59 | const usesDtcg = Object.hasOwn(token, '$value'); 60 | const { value, $value, type, $type } = token; 61 | const tokenType = (usesDtcg ? $type : type) as string; 62 | const tokenValue = (usesDtcg ? $value : value) as 63 | | (TokenTypographyValue & { fontStyle: string }) 64 | | SingleFontWeightsToken['value']; 65 | 66 | if (tokenType === 'typography') { 67 | const tokenTypographyValue = tokenValue as TokenTypographyValue & { fontStyle: string }; 68 | if (tokenTypographyValue.fontWeight === undefined) return; 69 | 70 | const fontWeight = resolveFontWeight( 71 | `${tokenTypographyValue.fontWeight}`, 72 | refCopy, 73 | usesDtcg, 74 | ); 75 | const { weight, style } = splitWeightStyle(fontWeight, alwaysAddFontStyle); 76 | if (style) { 77 | tokenTypographyValue.fontWeight = weight; 78 | tokenTypographyValue.fontStyle = style; 79 | } 80 | } else if (tokenType === 'fontWeight') { 81 | const tokenFontWeightsValue = tokenValue as SingleFontWeightsToken['value']; 82 | const fontWeight = resolveFontWeight(`${tokenFontWeightsValue}`, refCopy, usesDtcg); 83 | // alwaysAddFontStyle should only apply to typography tokens, so we pass `false` here 84 | const { weight, style } = splitWeightStyle(fontWeight, false); 85 | 86 | if (style) { 87 | // since tokenFontWeightsValue is a primitive (string), we have to permutate the change directly 88 | (slice[key] as DeepKeyTokenMap) = { 89 | weight: { 90 | ...token, 91 | [`${usesDtcg ? '$' : ''}type`]: 'fontWeight', 92 | [`${usesDtcg ? '$' : ''}value`]: weight, 93 | }, 94 | style: { 95 | ...token, 96 | [`${usesDtcg ? '$' : ''}type`]: 'fontStyle', 97 | [`${usesDtcg ? '$' : ''}value`]: style, 98 | }, 99 | }; 100 | } 101 | } 102 | } else if (typeof potentiallyToken === 'object') { 103 | recurse( 104 | potentiallyToken as DeepKeyTokenMap | SingleToken, 105 | refCopy, 106 | alwaysAddFontStyle, 107 | ); 108 | } 109 | }); 110 | } 111 | 112 | export function addFontStyles( 113 | dictionary: DeepKeyTokenMap, 114 | transformOpts?: TransformOptions, 115 | ): DeepKeyTokenMap { 116 | const refCopy = structuredClone(dictionary); 117 | const newCopy = structuredClone(dictionary); 118 | recurse(newCopy, refCopy, transformOpts?.alwaysAddFontStyle); 119 | return newCopy; 120 | } 121 | -------------------------------------------------------------------------------- /src/preprocessors/align-types.ts: -------------------------------------------------------------------------------- 1 | import { DeepKeyTokenMap, SingleToken, TokenTypes } from '@tokens-studio/types'; 2 | import { typeDtcgDelegate } from 'style-dictionary/utils'; 3 | 4 | // TODO: composition tokens props also need the same types alignments.. 5 | // nested composition tokens are out of scope. 6 | 7 | type valueOfTokenTypes = (typeof TokenTypes)[keyof typeof TokenTypes]; 8 | 9 | const typesMap = { 10 | fontFamilies: 'fontFamily', 11 | fontWeights: 'fontWeight', 12 | fontSizes: 'fontSize', 13 | lineHeights: 'lineHeight', 14 | boxShadow: 'shadow', 15 | spacing: 'dimension', 16 | sizing: 'dimension', 17 | borderRadius: 'dimension', 18 | borderWidth: 'dimension', 19 | letterSpacing: 'dimension', 20 | paragraphSpacing: 'dimension', 21 | paragraphIndent: 'dimension', 22 | text: 'content', 23 | } as Partial>; 24 | 25 | const propsMap = { 26 | shadow: { 27 | x: 'offsetX', 28 | y: 'offsetY', 29 | }, 30 | } as Partial>>; 31 | 32 | function recurse(slice: DeepKeyTokenMap | SingleToken) { 33 | const isToken = 34 | (Object.hasOwn(slice, '$type') && Object.hasOwn(slice, '$value')) || 35 | (Object.hasOwn(slice, 'type') && Object.hasOwn(slice, 'value')); 36 | if (isToken) { 37 | const { $value, value, type, $type } = slice; 38 | const usesDTCG = Object.hasOwn(slice, '$value'); 39 | const t = (usesDTCG ? $type : type) as valueOfTokenTypes; 40 | const v = usesDTCG ? $value : value; 41 | const tProp = `${usesDTCG ? '$' : ''}type` as '$type' | 'type'; 42 | const newT = (typesMap[t as keyof typeof typesMap] ?? t) as valueOfTokenTypes; 43 | const k = 'studio.tokens' as keyof typeof slice.$extensions; 44 | 45 | if (newT !== t) { 46 | // replace the type with new type 47 | (slice[tProp] as valueOfTokenTypes) = newT; 48 | // store the original type as metadata 49 | slice.$extensions = { 50 | ...slice.$extensions, 51 | [k]: { 52 | ...(slice.$extensions?.[k] ?? {}), 53 | originalType: t as TokenTypes, 54 | }, 55 | }; 56 | } 57 | 58 | // now also check propsMap if we need to map some props 59 | if (typeof v === 'object') { 60 | const pMap = propsMap[newT as keyof typeof propsMap]; 61 | if (pMap) { 62 | const convertProps = (obj: Record) => { 63 | Object.entries(pMap).forEach(([key, propValue]) => { 64 | if (obj[key] !== undefined) { 65 | obj[propValue] = obj[key]; 66 | delete obj[key]; 67 | } 68 | }); 69 | }; 70 | 71 | const newV = v; 72 | if (Array.isArray(newV)) { 73 | newV.forEach(convertProps); 74 | } else { 75 | convertProps(newV); 76 | } 77 | slice[`${usesDTCG ? '$' : ''}value`] = newV; 78 | } 79 | } 80 | } else { 81 | Object.values(slice).forEach(val => { 82 | if (typeof val === 'object') { 83 | recurse(val); 84 | } 85 | }); 86 | } 87 | } 88 | 89 | export function alignTypes(dictionary: DeepKeyTokenMap): DeepKeyTokenMap { 90 | // pre-emptively type dtcg delegate because otherwise we cannot align types properly here 91 | const copy = typeDtcgDelegate(structuredClone(dictionary)); 92 | recurse(copy); 93 | return copy; 94 | } 95 | -------------------------------------------------------------------------------- /src/preprocessors/exclude-parent-keys.ts: -------------------------------------------------------------------------------- 1 | import { DeepKeyTokenMap } from '@tokens-studio/types'; 2 | // @ts-expect-error untyped library... 3 | import deepmerge from '@bundled-es-modules/deepmerge'; 4 | import { TransformOptions } from '../TransformOptions.js'; 5 | 6 | export function excludeParentKeys( 7 | dictionary: DeepKeyTokenMap, 8 | transformOpts?: TransformOptions, 9 | ): DeepKeyTokenMap { 10 | if (!transformOpts?.excludeParentKeys) { 11 | return dictionary; 12 | } 13 | const copy = {} as DeepKeyTokenMap; 14 | Object.values(dictionary).forEach(set => { 15 | Object.entries(set).forEach(([key, tokenGroup]) => { 16 | if (copy[key]) { 17 | copy[key] = deepmerge(copy[key] as DeepKeyTokenMap, tokenGroup); 18 | } else { 19 | copy[key] = tokenGroup; 20 | } 21 | }); 22 | }); 23 | return copy; 24 | } 25 | -------------------------------------------------------------------------------- /src/preprocessors/parse-tokens.ts: -------------------------------------------------------------------------------- 1 | import { DeepKeyTokenMap } from '@tokens-studio/types'; 2 | import { TransformOptions } from '../TransformOptions.js'; 3 | import { excludeParentKeys } from './exclude-parent-keys.js'; 4 | import { addFontStyles } from './add-font-styles.js'; 5 | import { alignTypes } from './align-types.js'; 6 | 7 | export function parseTokens(tokens: DeepKeyTokenMap, transformOpts?: TransformOptions) { 8 | const excluded = excludeParentKeys(tokens, transformOpts); 9 | const alignedTypes = alignTypes(excluded); 10 | const withFontStyles = addFontStyles(alignedTypes, transformOpts); 11 | return withFontStyles; 12 | } 13 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | import type { PreprocessedTokens } from 'style-dictionary/types'; 2 | import StyleDictionary from 'style-dictionary'; 3 | import { transformDimension } from './transformDimension.js'; 4 | import { transformHEXRGBaForCSS } from './css/transformHEXRGBa.js'; 5 | import { transformFontWeight } from './transformFontWeight.js'; 6 | import { transformLetterSpacingForCSS } from './css/transformLetterSpacing.js'; 7 | import { transformLineHeight } from './transformLineHeight.js'; 8 | import { transformTypographyForCompose } from './compose/transformTypography.js'; 9 | import { checkAndEvaluateMath } from './checkAndEvaluateMath.js'; 10 | import { mapDescriptionToComment } from './mapDescriptionToComment.js'; 11 | import { transformColorModifiers } from './color-modifiers/transformColorModifiers.js'; 12 | import { TransformOptions } from './TransformOptions.js'; 13 | import { transformOpacity } from './transformOpacity.js'; 14 | import { parseTokens } from './preprocessors/parse-tokens.js'; 15 | import { transformShadow } from './css/transformShadow.js'; 16 | 17 | export const getTransforms = (transformOpts?: TransformOptions) => { 18 | const agnosticTransforms = [ 19 | 'ts/descriptionToComment', 20 | 'ts/resolveMath', 21 | 'ts/size/px', 22 | 'ts/opacity', 23 | 'ts/size/lineheight', 24 | 'ts/typography/fontWeight', 25 | 'ts/color/modifiers', 26 | ]; 27 | 28 | const platformTransforms = { 29 | css: ['ts/color/css/hexrgba', 'ts/size/css/letterspacing', 'ts/shadow/innerShadow'], 30 | compose: ['ts/typography/compose/shorthand'], 31 | }; 32 | 33 | const platform = transformOpts?.platform ?? 'css'; 34 | 35 | return [...agnosticTransforms, ...(platformTransforms[platform] ?? [])]; 36 | }; 37 | 38 | /** 39 | * typecasting since this will need to work in browser environment, so we cannot 40 | * import style-dictionary as it depends on nodejs env 41 | */ 42 | export async function register(sd: typeof StyleDictionary, transformOpts?: TransformOptions) { 43 | sd.registerPreprocessor({ 44 | name: 'tokens-studio', 45 | preprocessor: dictionary => { 46 | return parseTokens(dictionary, transformOpts) as PreprocessedTokens; 47 | }, 48 | }); 49 | 50 | sd.registerTransform({ 51 | name: 'ts/descriptionToComment', 52 | type: 'attribute', 53 | filter: token => token.description, 54 | transform: token => mapDescriptionToComment(token), 55 | }); 56 | 57 | /** 58 | * The transforms below are transitive transforms, because their values 59 | * can contain references, e.g.: 60 | * - rgba({color.r}, {color.g}, 0, 0) 61 | * - {dimension.scale} * {spacing.sm} 62 | * - { fontSize: "{foo}" } 63 | * - { width: "{bar}" } 64 | * - { blur: "{qux}" } 65 | * or because the modifications have to be done on this specific token, 66 | * after resolution, e.g. color modify or resolve math 67 | * 68 | * Any transforms that may need to occur after resolving of math therefore also 69 | * need to be transitive... which means basically every transform we have. 70 | */ 71 | sd.registerTransform({ 72 | name: 'ts/resolveMath', 73 | type: 'value', 74 | transitive: true, 75 | filter: token => ['string', 'object'].includes(typeof (token.$value ?? token.value)), 76 | transform: (token, platformCfg) => checkAndEvaluateMath(token, platformCfg.mathFractionDigits), 77 | }); 78 | 79 | sd.registerTransform({ 80 | name: 'ts/size/px', 81 | type: 'value', 82 | transitive: true, 83 | filter: token => { 84 | const type = token.$type ?? token.type; 85 | return ( 86 | typeof type === 'string' && 87 | ['fontSize', 'dimension', 'typography', 'border', 'shadow'].includes(type) 88 | ); 89 | }, 90 | transform: token => transformDimension(token), 91 | }); 92 | 93 | sd.registerTransform({ 94 | name: 'ts/opacity', 95 | type: 'value', 96 | transitive: true, 97 | filter: token => (token.$type ?? token.type) === 'opacity', 98 | transform: token => transformOpacity(token.$value ?? token.value), 99 | }); 100 | 101 | sd.registerTransform({ 102 | name: 'ts/size/css/letterspacing', 103 | type: 'value', 104 | transitive: true, 105 | filter: token => { 106 | const type = token.$type ?? token.type; 107 | const originalType = token.$extensions?.['studio.tokens']?.originalType; 108 | return ( 109 | typeof type === 'string' && 110 | (['letterSpacing', 'typography'].includes(type) || originalType === 'letterSpacing') 111 | ); 112 | }, 113 | transform: token => transformLetterSpacingForCSS(token), 114 | }); 115 | 116 | sd.registerTransform({ 117 | name: 'ts/size/lineheight', 118 | type: 'value', 119 | transitive: true, 120 | filter: token => { 121 | const type = token.$type ?? token.type; 122 | return typeof type === 'string' && ['lineHeight', 'typography'].includes(type); 123 | }, 124 | transform: token => transformLineHeight(token), 125 | }); 126 | 127 | sd.registerTransform({ 128 | name: 'ts/typography/fontWeight', 129 | type: 'value', 130 | transitive: true, 131 | filter: token => { 132 | const type = token.$type ?? token.type; 133 | return typeof type === 'string' && ['fontWeight', 'typography'].includes(type); 134 | }, 135 | transform: token => transformFontWeight(token), 136 | }); 137 | 138 | sd.registerTransform({ 139 | name: 'ts/shadow/innerShadow', 140 | type: 'value', 141 | transitive: true, 142 | filter: token => (token.$type ?? token.type) === 'shadow', 143 | transform: token => transformShadow(token.$value ?? token.value), 144 | }); 145 | 146 | sd.registerTransform({ 147 | name: 'ts/typography/compose/shorthand', 148 | type: 'value', 149 | transitive: true, 150 | filter: token => (token.$type ?? token.type) === 'typography', 151 | transform: token => transformTypographyForCompose(token), 152 | }); 153 | 154 | sd.registerTransform({ 155 | name: 'ts/color/css/hexrgba', 156 | type: 'value', 157 | transitive: true, 158 | filter: token => { 159 | const type = token.$type ?? token.type; 160 | return typeof type === 'string' && ['color', 'shadow', 'border'].includes(type); 161 | }, 162 | transform: token => transformHEXRGBaForCSS(token), 163 | }); 164 | 165 | sd.registerTransform({ 166 | name: 'ts/color/modifiers', 167 | type: 'value', 168 | transitive: true, 169 | filter: token => 170 | typeof (token.$value ?? token.value) === 'string' && 171 | (token.$type ?? token.type) === 'color' && 172 | token.$extensions && 173 | token.$extensions['studio.tokens']?.modify, 174 | transform: token => transformColorModifiers(token, transformOpts?.['ts/color/modifiers']), 175 | }); 176 | 177 | const includeBuiltinGroup = transformOpts?.withSDBuiltins ?? true; 178 | // append `?? []` on the line below once we add more platforms which SD may not have a builtin transformGroup for 179 | const builtinTransforms = sd.hooks.transformGroups[transformOpts?.platform ?? 'css']; 180 | 181 | sd.registerTransformGroup({ 182 | name: transformOpts?.name ?? 'tokens-studio', 183 | // add a default name transform, since this is almost always needed 184 | // it's easy to override by users, adding their own "transforms" 185 | transforms: [ 186 | ...getTransforms(transformOpts), 187 | // append the built-in style-dictionary transformGroup's transforms 188 | ...(includeBuiltinGroup ? builtinTransforms : []), 189 | 'name/camel', 190 | ], 191 | }); 192 | } 193 | -------------------------------------------------------------------------------- /src/transformDimension.ts: -------------------------------------------------------------------------------- 1 | import { DesignToken } from 'style-dictionary/types'; 2 | 3 | function transformDimensionValue(value: string | number): string { 4 | return splitMultiValues(value).map(ensurePxSuffix).join(' '); 5 | } 6 | 7 | function ensurePxSuffix(dim: string | number): string { 8 | if (!isNaN(dim as number) && dim !== '' && parseFloat(dim as string) !== 0) { 9 | return `${dim}px`; 10 | } 11 | return `${dim}`; 12 | } 13 | 14 | function splitMultiValues(dim: string | number): string[] { 15 | if (typeof dim === 'string' && dim.includes(' ')) { 16 | return dim.split(' '); 17 | } 18 | return [`${dim}`]; 19 | } 20 | 21 | function transformDimensionProp( 22 | val: Record, 23 | prop: string, 24 | ): Record { 25 | if (val[prop] !== undefined) { 26 | val[prop] = transformDimensionValue(val[prop]); 27 | } 28 | return val; 29 | } 30 | 31 | /** 32 | * Helper: Transforms dimensions to px 33 | */ 34 | export function transformDimension(token: DesignToken): DesignToken['value'] { 35 | const val = (token.$value ?? token.value) as 36 | | Record 37 | | Record[] 38 | | number 39 | | string; 40 | 41 | const type = token.$type ?? token.type; 42 | 43 | if (val === undefined) return undefined; 44 | 45 | let transformed = val; 46 | 47 | switch (type) { 48 | case 'typography': { 49 | transformed = transformDimensionProp(val as Record, 'fontSize'); 50 | break; 51 | } 52 | case 'shadow': { 53 | const transformShadow = (shadowVal: Record) => { 54 | ['offsetX', 'offsetY', 'blur', 'spread'].forEach(prop => 55 | transformDimensionProp(shadowVal, prop), 56 | ); 57 | return shadowVal; 58 | }; 59 | if (Array.isArray(transformed)) { 60 | transformed = transformed.map(transformShadow); 61 | } else { 62 | transformed = transformShadow(transformed as Record); 63 | } 64 | break; 65 | } 66 | case 'border': { 67 | transformed = transformDimensionProp(val as Record, 'width'); 68 | break; 69 | } 70 | default: { 71 | transformed = transformDimensionValue(val as string | number); 72 | } 73 | } 74 | 75 | return transformed; 76 | } 77 | -------------------------------------------------------------------------------- /src/transformFontWeight.ts: -------------------------------------------------------------------------------- 1 | import { DesignToken } from 'style-dictionary/types'; 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping 4 | export const fontWeightMap = { 5 | hairline: 100, 6 | thin: 100, 7 | extralight: 200, 8 | ultralight: 200, 9 | extraleicht: 200, 10 | light: 300, 11 | leicht: 300, 12 | normal: 400, 13 | regular: 400, 14 | buch: 400, 15 | book: 400, 16 | medium: 500, 17 | kraeftig: 500, 18 | kräftig: 500, 19 | semibold: 600, 20 | demibold: 600, 21 | halbfett: 600, 22 | bold: 700, 23 | dreiviertelfett: 700, 24 | extrabold: 800, 25 | ultrabold: 800, 26 | fett: 800, 27 | black: 900, 28 | heavy: 900, 29 | super: 900, 30 | extrafett: 900, 31 | ultra: 950, 32 | ultrablack: 950, 33 | extrablack: 950, 34 | }; 35 | 36 | export const fontStyles = ['italic', 'oblique', 'normal']; 37 | export const fontWeightReg = new RegExp( 38 | `(?.+?)\\s?(?