├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── features.yml │ └── bugs.yml └── workflows │ ├── verify.yml │ └── release.yml ├── .husky └── pre-commit ├── test ├── integration │ ├── tokens │ │ ├── custom-group.tokens.json │ │ ├── cross-file-refs-3.tokens.json │ │ ├── exclude-parent-keys.tokens.json │ │ ├── output-references.tokens.json │ │ ├── cross-file-refs-2.tokens.json │ │ ├── swift-UI-colors.tokens.json │ │ ├── color-modifier-references.tokens.json │ │ ├── cross-file-refs-1.tokens.json │ │ ├── math-in-complex-values.tokens.json │ │ ├── object-value-references.tokens.json │ │ ├── expand-composition.tokens.json │ │ ├── w3c-spec-compliance.tokens.json │ │ └── sd-transforms.tokens.json │ ├── utils.ts │ ├── color-modifier-references.test.ts │ ├── exclude-parent-keys.test.ts │ ├── custom-group.test.ts │ ├── output-references.test.ts │ ├── math-in-complex-values.test.ts │ ├── object-value-references.test.ts │ ├── swift-UI-color.test.ts │ ├── cross-file-refs.test.ts │ ├── w3c-spec-compliance.test.ts │ ├── sd-transforms.test.ts │ └── expand-composition.test.ts ├── spec │ ├── utils │ │ └── percentageToDecimal.spec.ts │ ├── transformOpacity.spec.ts │ ├── transformLineHeight.spec.ts │ ├── css │ │ ├── transformLetterSpacing.spec.ts │ │ ├── transformFontFamilies.spec.ts │ │ ├── transformBorder.spec.ts │ │ ├── transformShadow.spec.ts │ │ ├── transformHEXRGBa.spec.ts │ │ └── transformTypographyForCSS.spec.ts │ ├── transformDimension.spec.ts │ ├── mapDescriptionToComment.spec.ts │ ├── transformFontWeights.spec.ts │ ├── compose │ │ └── transformTypographyForCompose.spec.ts │ ├── parsers │ │ ├── excludeParentKeys.spec.ts │ │ └── add-font-styles.spec.ts │ ├── checkAndEvaluateMath.spec.ts │ ├── permutateThemes.spec.ts │ └── color-modifiers │ │ └── transformColorModifiers.spec.ts └── suites │ └── transform-suite.spec.ts ├── tsconfig.build.json ├── .lintstagedrc.js ├── src ├── utils │ ├── is-nothing.ts │ └── percentageToDecimal.ts ├── color-modifiers │ ├── transparentize.ts │ ├── mix.ts │ ├── transformColorModifiers.ts │ ├── darken.ts │ ├── lighten.ts │ └── modifyColor.ts ├── css │ ├── transformLetterSpacing.ts │ ├── transformHEXRGBa.ts │ ├── transformBorder.ts │ ├── transformShadow.ts │ └── transformTypography.ts ├── transformOpacity.ts ├── transformLineHeight.ts ├── mapDescriptionToComment.ts ├── transformDimension.ts ├── parsers │ ├── parse-tokens.ts │ ├── exclude-parent-keys.ts │ ├── add-font-styles.ts │ └── expand-composites.ts ├── compose │ └── transformTypography.ts ├── index.ts ├── TransformOptions.ts ├── transformFontWeights.ts ├── permutateThemes.ts ├── checkAndEvaluateMath.ts └── registerTransforms.ts ├── .eslintignore ├── .prettierignore ├── .gitignore ├── .changeset ├── config.json └── README.md ├── .eslintrc.cjs ├── tsconfig.json ├── web-test-runner.config.mjs ├── LICENSE ├── handle-bundled-cjs-deps.js ├── CONTRIBUTING.md ├── rollup.config.mjs ├── package.json ├── CHANGELOG.md └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /test/integration/tokens/custom-group.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "length": { 3 | "value": 24, 4 | "type": "sizing" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "**/coverage/*", "test/**/*"] 4 | } 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 | -------------------------------------------------------------------------------- /src/utils/is-nothing.ts: -------------------------------------------------------------------------------- 1 | export function isNothing(value: string | number | null | undefined): boolean { 2 | if (value == null || value === '') { 3 | return true; 4 | } 5 | return false; 6 | } 7 | -------------------------------------------------------------------------------- /test/integration/tokens/cross-file-refs-3.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "typo-alias": { 3 | "value": "{typo}", 4 | "type": "typography" 5 | }, 6 | "typo-3": { 7 | "value": "{typo-2}", 8 | "type": "typography" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/color-modifiers/mix.ts: -------------------------------------------------------------------------------- 1 | import Color from 'colorjs.io'; 2 | 3 | export function mix(color: Color, amount: number, mixColor: Color): Color { 4 | const mixValue = Math.max(0, Math.min(1, Number(amount))); 5 | 6 | return new Color(color.mix(mixColor, mixValue).toString()); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/percentageToDecimal.ts: -------------------------------------------------------------------------------- 1 | export function percentageToDecimal(value: string | number): string | number { 2 | if (!`${value}`.endsWith('%')) { 3 | return `${value}`; 4 | } 5 | const percentValue = `${value}`.slice(0, -1); 6 | const numberValue = parseFloat(percentValue); 7 | return numberValue / 100; 8 | } 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/css/transformLetterSpacing.ts: -------------------------------------------------------------------------------- 1 | import { percentageToDecimal } from '../utils/percentageToDecimal.js'; 2 | 3 | /** 4 | * Helper: Transforms letter spacing % to em 5 | */ 6 | export function transformLetterSpacingForCSS( 7 | value: string | number | undefined, 8 | ): string | undefined { 9 | if (value === undefined) { 10 | return value; 11 | } 12 | const decimal = percentageToDecimal(value); 13 | return typeof decimal === 'string' || isNaN(decimal) ? `${value}` : `${decimal}em`; 14 | } 15 | -------------------------------------------------------------------------------- /test/integration/tokens/exclude-parent-keys.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": { 3 | "core": { 4 | "color": { 5 | "value": "#FFFFFF", 6 | "type": "color" 7 | } 8 | }, 9 | "semantic": { 10 | "color": { 11 | "value": "{core.color}", 12 | "type": "color" 13 | } 14 | } 15 | }, 16 | "bar": { 17 | "button": { 18 | "color": { 19 | "value": "{semantic.color}", 20 | "type": "color" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/transformOpacity.ts: -------------------------------------------------------------------------------- 1 | import { percentageToDecimal } from './utils/percentageToDecimal.js'; 2 | 3 | /** 4 | * Helper: Transforms opacity % to a decimal point number 5 | * @example 6 | * 50% -> 0.5 7 | */ 8 | export function transformOpacity(value: string | number | undefined): string | number | undefined { 9 | if (value === undefined) { 10 | return value; 11 | } 12 | const decimal = percentageToDecimal(value); 13 | return typeof decimal === 'string' || isNaN(decimal) ? value : decimal; 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/transformLineHeight.ts: -------------------------------------------------------------------------------- 1 | import { percentageToDecimal } from './utils/percentageToDecimal.js'; 2 | 3 | /** 4 | * Helper: Transforms line-height % to unit-less decimal value 5 | * @example 6 | * 150% -> 1.5 7 | */ 8 | export function transformLineHeight( 9 | value: string | number | undefined, 10 | ): string | number | undefined { 11 | if (value === undefined) { 12 | return value; 13 | } 14 | const decimal = percentageToDecimal(value); 15 | return typeof decimal === 'string' || isNaN(decimal) ? `${value}` : decimal; 16 | } 17 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | _t.comment = _t.description.replace(/\r?\n|\r/g, ' '); // Strip out newline and carriage returns, replacing them with space 11 | return _t; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["ESNext", "dom"], 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "noImplicitThis": true, 10 | "alwaysStrict": true, 11 | "types": ["node", "mocha"], 12 | "esModuleInterop": true, 13 | "noImplicitAny": false, 14 | "outDir": "dist/src", 15 | "declaration": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": ["**/*.ts", "**/*.d.ts"], 19 | "exclude": ["node_modules", "**/coverage/*"] 20 | } 21 | -------------------------------------------------------------------------------- /test/integration/tokens/output-references.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "my": { 3 | "base": { 4 | "token": { 5 | "value": "11", 6 | "type": "spacing" 7 | } 8 | }, 9 | "reference": { 10 | "token": { 11 | "value": "{my.base.token}", 12 | "type": "spacing" 13 | } 14 | } 15 | }, 16 | "transformed": { 17 | "base": { 18 | "token": { 19 | "value": "4", 20 | "type": "spacing" 21 | } 22 | }, 23 | "reference": { 24 | "token": { 25 | "value": "{my.base.token} * 5", 26 | "type": "spacing" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/integration/tokens/cross-file-refs-2.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "weight": { 3 | "value": "Regular italic", 4 | "type": "fontWeights" 5 | }, 6 | "testComposite": { 7 | "card": { 8 | "value": { 9 | "color": "#fff", 10 | "borderRadius": "18px", 11 | "borderColor": "#999" 12 | }, 13 | "type": "composition" 14 | } 15 | }, 16 | "testTypography": { 17 | "text": { 18 | "value": { 19 | "fontFamily": "Arial", 20 | "fontSize": "25px", 21 | "lineHeight": "32px", 22 | "fontWeight": "700" 23 | }, 24 | "type": "typography" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/transformDimension.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper: Transforms dimensions to px 3 | */ 4 | export function transformDimension(value: string | undefined | number): string | undefined { 5 | if (value === undefined) { 6 | return value; 7 | } 8 | 9 | // Check if the value is numeric with isNaN, this supports string values as well 10 | // Check if value is not empty string, since this is also not considered "NaN" 11 | // Check if the value, when parsed (since it can also be number), does not equal 0 12 | if (!isNaN(value as number) && value !== '' && parseFloat(value as string) !== 0) { 13 | return `${value}px`; 14 | } 15 | return `${value}`; 16 | } 17 | -------------------------------------------------------------------------------- /src/parsers/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 { expandComposites } from './expand-composites.js'; 6 | 7 | export function parseTokens( 8 | tokens: DeepKeyTokenMap, 9 | transformOpts?: TransformOptions, 10 | filePath?: string, 11 | ) { 12 | const excluded = excludeParentKeys(tokens, transformOpts); 13 | const withFontStyles = addFontStyles(excluded, transformOpts); 14 | const expanded = expandComposites(withFontStyles, transformOpts, filePath); 15 | return expanded; 16 | } 17 | -------------------------------------------------------------------------------- /test/spec/utils/percentageToDecimal.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { percentageToDecimal } from '../../../src/utils/percentageToDecimal.js'; 3 | 4 | describe('percentage to decimal', () => { 5 | it('converts percentage strings to decimal numbers', () => { 6 | expect(percentageToDecimal('100%')).to.equal(1); 7 | expect(percentageToDecimal('50%')).to.equal(0.5); 8 | }); 9 | 10 | it('ignores values that do not end with a percentage character', () => { 11 | expect(percentageToDecimal('100')).to.equal('100'); 12 | expect(percentageToDecimal('foo')).to.equal('foo'); 13 | }); 14 | 15 | it('returns NaN if percentage cannot be parsed as a float', () => { 16 | expect(percentageToDecimal('foo%')).to.be.NaN; 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/suites/transform-suite.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { DesignToken } from 'style-dictionary/types'; 3 | 4 | export function runTransformSuite(transformer: (value: unknown) => unknown, token?: DesignToken) { 5 | describe('Test Suite: Transforms', () => { 6 | it('returns undefined if a token value is undefined, which can happen when there are broken references in token values', () => { 7 | // Most transformers only are passed the token value, but in some transformers, the full token object is passed 8 | if (token) { 9 | expect(transformer({ ...token, value: undefined })).to.equal(undefined); 10 | } else { 11 | expect(transformer(undefined)).to.equal(undefined); 12 | } 13 | }); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /test/spec/transformOpacity.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformOpacity } from '../../src/transformOpacity.js'; 3 | import { runTransformSuite } from '../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformOpacity as (value: unknown) => unknown); 6 | 7 | describe('transform opacity', () => { 8 | it('transforms opacity % to unit-less decimal value', () => { 9 | expect(transformOpacity('50%')).to.equal(0.5); 10 | }); 11 | 12 | it("does not transform opacity if it doesn't end with %", () => { 13 | expect(transformOpacity('100')).to.equal('100'); 14 | }); 15 | 16 | it('does not transform opacity if it cannot be parsed as float', () => { 17 | expect(transformOpacity('not-a-float%')).to.equal('not-a-float%'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/integration/tokens/swift-UI-colors.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": { 3 | "red": { 4 | "value": "#f00", 5 | "type": "color" 6 | }, 7 | "danger": { 8 | "value": "{color.red}", 9 | "type": "color", 10 | "$extensions": { 11 | "studio.tokens": { 12 | "modify": { 13 | "type": "darken", 14 | "value": "0.75", 15 | "space": "hsl" 16 | } 17 | } 18 | } 19 | }, 20 | "error": { 21 | "value": "{color.danger}", 22 | "type": "color", 23 | "$extensions": { 24 | "studio.tokens": { 25 | "modify": { 26 | "type": "darken", 27 | "value": "0.5", 28 | "space": "hsl" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/parsers/exclude-parent-keys.ts: -------------------------------------------------------------------------------- 1 | import { DeepKeyTokenMap } from '@tokens-studio/types'; 2 | import deepmerge from 'deepmerge'; 3 | import { TransformOptions } from '../TransformOptions.js'; 4 | 5 | export function excludeParentKeys( 6 | dictionary: DeepKeyTokenMap, 7 | transformOpts?: TransformOptions, 8 | ): DeepKeyTokenMap { 9 | if (!transformOpts?.excludeParentKeys) { 10 | return dictionary; 11 | } 12 | const copy = {} as DeepKeyTokenMap; 13 | Object.values(dictionary).forEach(set => { 14 | Object.entries(set).forEach(([key, tokenGroup]) => { 15 | if (copy[key]) { 16 | copy[key] = deepmerge(copy[key] as DeepKeyTokenMap, tokenGroup); 17 | } else { 18 | copy[key] = tokenGroup; 19 | } 20 | }); 21 | }); 22 | return copy; 23 | } 24 | -------------------------------------------------------------------------------- /src/css/transformHEXRGBa.ts: -------------------------------------------------------------------------------- 1 | import { parseToRgba } from 'color2k'; 2 | 3 | /** 4 | * Helper: Transforms hex rgba colors used in figma tokens: 5 | * rgba(#ffffff, 0.5) =? rgba(255, 255, 255, 0.5). 6 | * This is kind of like an alpha() function. 7 | */ 8 | export function transformHEXRGBaForCSS(value: string | undefined): string | undefined { 9 | if (value === undefined) { 10 | return value; 11 | } 12 | 13 | const regex = /rgba\(\s*(?#.+?)\s*,\s*(?\d*(\.\d*|%)*)\s*\)/g; 14 | 15 | return value.replace(regex, (match, hex, alpha) => { 16 | try { 17 | const [r, g, b] = parseToRgba(hex); 18 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 19 | } catch (e) { 20 | console.warn(`Tried parsing "${hex}" as a hex value, but failed.`); 21 | return match; 22 | } 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /test/integration/tokens/color-modifier-references.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "alpha": { 3 | "value": 0.3, 4 | "type": "other" 5 | }, 6 | "color": { 7 | "value": "#FFFFFF", 8 | "type": "color", 9 | "$extensions": { 10 | "studio.tokens": { 11 | "modify": { 12 | "type": "alpha", 13 | "value": "{alpha}", 14 | "space": "srgb", 15 | "format": "hex" 16 | } 17 | } 18 | } 19 | }, 20 | "modifier": { 21 | "value": { 22 | "type": "alpha", 23 | "value": "{alpha}", 24 | "space": "srgb", 25 | "format": "hex" 26 | } 27 | }, 28 | "color2": { 29 | "value": "#000000", 30 | "type": "color", 31 | "$extensions": { 32 | "studio.tokens": { 33 | "modify": "{modifier}" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/spec/transformLineHeight.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformLineHeight } from '../../src/transformLineHeight.js'; 3 | import { runTransformSuite } from '../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformLineHeight as (value: unknown) => unknown); 6 | 7 | describe('transform line height', () => { 8 | it('transforms line-height % to unit-less decimal value', () => { 9 | expect(transformLineHeight('50%')).to.equal(0.5); 10 | }); 11 | 12 | it("does not transform line-height if it doesn't end with %", () => { 13 | expect(transformLineHeight('100')).to.equal('100'); 14 | }); 15 | 16 | it('does not transform line-height if it cannot be parsed as float', () => { 17 | expect(transformLineHeight('not-a-float%')).to.equal('not-a-float%'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { playwrightLauncher } from '@web/test-runner-playwright'; 2 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 3 | import { fromRollup } from '@web/dev-server-rollup'; 4 | import commonjsRollup from '@rollup/plugin-commonjs'; 5 | 6 | const commonjs = fromRollup(commonjsRollup); 7 | 8 | export default { 9 | nodeResolve: true, 10 | files: ['test/**/*.spec.ts'], 11 | coverageConfig: { 12 | report: true, 13 | reportDir: 'coverage', 14 | threshold: { 15 | statements: 100, 16 | branches: 100, 17 | functions: 100, 18 | lines: 100, 19 | }, 20 | }, 21 | browsers: [playwrightLauncher({ product: 'chromium' })], 22 | plugins: [ 23 | commonjs({ requireReturnsDefault: 'preferred' }), 24 | esbuildPlugin({ ts: true, target: 'auto' }), 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /.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-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 | -------------------------------------------------------------------------------- /test/spec/css/transformLetterSpacing.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformLetterSpacingForCSS } from '../../../src/css/transformLetterSpacing.js'; 3 | import { runTransformSuite } from '../../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformLetterSpacingForCSS as (value: unknown) => unknown); 6 | 7 | describe('transform letter spacing', () => { 8 | it('transforms letter spacing % to em', () => { 9 | expect(transformLetterSpacingForCSS('50%')).to.equal('0.5em'); 10 | }); 11 | 12 | it("does not transform letter spacing if it doesn't end with %", () => { 13 | expect(transformLetterSpacingForCSS('100')).to.equal('100'); 14 | }); 15 | 16 | it('does not transform letter spacing if it cannot be parsed as float', () => { 17 | expect(transformLetterSpacingForCSS('not-a-float%')).to.equal('not-a-float%'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/integration/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'style-dictionary/types'; 2 | import StyleDictionary from 'style-dictionary'; 3 | import { registerTransforms } from '../../src/registerTransforms.js'; 4 | 5 | export async function init(cfg: Config, transformOpts = {}) { 6 | registerTransforms(StyleDictionary, transformOpts); 7 | const dict = new StyleDictionary(cfg); 8 | await dict.buildAllPlatforms(); 9 | return dict; 10 | } 11 | 12 | export async function cleanup(dict?: StyleDictionary) { 13 | // @ts-expect-error polluting dictionary it on purpose 14 | if (dict && !dict.cleaned) { 15 | await dict.cleanAllPlatforms(); 16 | // @ts-expect-error polluting dictionary it on purpose 17 | dict.cleaned = true; 18 | } 19 | StyleDictionary.parsers = []; 20 | delete StyleDictionary.transformGroup['tokens-studio']; 21 | Object.keys(StyleDictionary.transform).forEach(transform => { 22 | if (transform.startsWith('ts/')) { 23 | delete StyleDictionary.transform[transform]; 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /test/spec/transformDimension.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformDimension } from '../../src/transformDimension.js'; 3 | import { runTransformSuite } from '../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformDimension as (value: unknown) => unknown); 6 | 7 | describe('transform dimension', () => { 8 | it('transforms unitless dimensions, by suffixing with "px"', () => { 9 | expect(transformDimension('4')).to.equal('4px'); 10 | expect(transformDimension(4)).to.equal('4px'); 11 | }); 12 | 13 | it('does not transform a dimension if it already is suffixed with "px"', () => { 14 | expect(transformDimension('4px')).to.equal('4px'); 15 | }); 16 | 17 | it('does not transform a dimension if it is not numeric', () => { 18 | expect(transformDimension('4em')).to.equal('4em'); 19 | }); 20 | 21 | it('does not transform a dimension if it is 0', () => { 22 | expect(transformDimension('0')).to.equal('0'); 23 | expect(transformDimension(0)).to.equal('0'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/css/transformBorder.ts: -------------------------------------------------------------------------------- 1 | import { checkAndEvaluateMath } from '../checkAndEvaluateMath.js'; 2 | import { transformDimension } from '../transformDimension.js'; 3 | import { transformHEXRGBaForCSS } from './transformHEXRGBa.js'; 4 | import { isNothing } from '../utils/is-nothing.js'; 5 | 6 | /** 7 | * Helper: Transforms border object to border shorthand 8 | * This currently works fine if every value uses an alias, 9 | * but if any one of these use a raw value, it will not be transformed. 10 | */ 11 | export function transformBorderForCSS( 12 | border: Record | undefined | string, 13 | ): string | undefined { 14 | if (typeof border !== 'object') { 15 | return border; 16 | } 17 | let { color, width } = border; 18 | const { style } = border; 19 | width = transformDimension(checkAndEvaluateMath(width) as number | string | undefined); 20 | color = transformHEXRGBaForCSS(color); 21 | return `${isNothing(width) ? '' : width} ${isNothing(style) ? '' : style} ${ 22 | isNothing(color) ? '' : color 23 | }`.trim(); 24 | } 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /test/integration/tokens/cross-file-refs-1.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "typo": { 3 | "value": { 4 | "fontWeight": "{weight}" 5 | }, 6 | "type": "typography" 7 | }, 8 | "primaryFont": { 9 | "value": "Inter", 10 | "type": "fontFamilies" 11 | }, 12 | "fontWeight": { 13 | "value": "ExtraBold", 14 | "type": "fontWeights" 15 | }, 16 | "lineHeight": { 17 | "value": "1.5", 18 | "type": "lineHeights" 19 | }, 20 | "typo-2": { 21 | "value": { 22 | "fontFamily": "{primaryFont}", 23 | "fontWeight": "{fontWeight}", 24 | "lineHeight": "{lineHeight}", 25 | "fontSize": "{dimension.scale}*{dimension.xs}" 26 | }, 27 | "type": "typography" 28 | }, 29 | "dimension": { 30 | "scale": { 31 | "value": "2", 32 | "type": "number" 33 | }, 34 | "xs": { 35 | "value": "4px", 36 | "type": "number" 37 | } 38 | }, 39 | "testComposite": { 40 | "fancyCard": { 41 | "value": "{testComposite.card}", 42 | "type": "composition" 43 | } 44 | }, 45 | "testTypography": { 46 | "fancyText": { 47 | "value": "{testTypography.text}", 48 | "type": "typography" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /test/spec/css/transformFontFamilies.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { processFontFamily, escapeApostrophes } from '../../../src/css/transformTypography.js'; 3 | 4 | describe('process font family', () => { 5 | it('transforms font-family to have single quotes around multi-word font-families', () => { 6 | expect(processFontFamily('')).to.equal(`sans-serif`); 7 | expect(processFontFamily('Arial, sans-serif')).to.equal(`Arial, sans-serif`); 8 | expect(processFontFamily('Arial Black, sans-serif')).to.equal(`'Arial Black', sans-serif`); 9 | expect(processFontFamily('Arial Black, Times New Roman, Foo, sans-serif')).to.equal( 10 | `'Arial Black', 'Times New Roman', Foo, sans-serif`, 11 | ); 12 | expect( 13 | processFontFamily(`'Arial Black', Times New Roman, Suisse Int'l, Foo, sans-serif`), 14 | ).to.equal(`'Arial Black', 'Times New Roman', 'Suisse Int\\'l', Foo, sans-serif`); 15 | }); 16 | }); 17 | 18 | describe('escape apostrophes', () => { 19 | it('should escape single apostrophes in strings', () => { 20 | expect(escapeApostrophes("Suisse Int'l")).to.equal("Suisse Int\\'l"); 21 | expect(escapeApostrophes("Font's Example")).to.equal("Font\\'s Example"); 22 | expect(escapeApostrophes('NoEscape')).to.equal('NoEscape'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/integration/tokens/math-in-complex-values.tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "typo": { 3 | "value": { 4 | "fontFamily": "Arial Black", 5 | "fontSize": "16 * 1.5", 6 | "fontWeight": "regular", 7 | "lineHeight": "0.75 * 1.5" 8 | }, 9 | "type": "typography" 10 | }, 11 | "border": { 12 | "value": { 13 | "color": "rgba(#FFFF00, 0.5)", 14 | "width": "16 * 1.5", 15 | "style": "dashed" 16 | }, 17 | "type": "border" 18 | }, 19 | "shadow": { 20 | "single": { 21 | "value": { 22 | "x": "0", 23 | "y": "3 + 1", 24 | "blur": "100 / 10", 25 | "spread": "0", 26 | "color": "rgba(#000000,0.4)", 27 | "type": "innerShadow" 28 | }, 29 | "type": "boxShadow" 30 | }, 31 | "double": { 32 | "value": [ 33 | { 34 | "x": "0", 35 | "y": "3 + 1", 36 | "blur": "100 / 10", 37 | "spread": "0", 38 | "color": "rgba(#000000,0.4)", 39 | "type": "innerShadow" 40 | }, 41 | { 42 | "x": "0", 43 | "y": "5 - 1", 44 | "blur": "1 * 10", 45 | "spread": "0", 46 | "color": "rgba(#FFFFFF,0.2)", 47 | "type": "innerShadow" 48 | } 49 | ], 50 | "type": "boxShadow" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/spec/mapDescriptionToComment.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { mapDescriptionToComment } from '../../src/mapDescriptionToComment.js'; 3 | 4 | describe('map description to comment', () => { 5 | it('maps the token description to a style dictionary comment attribute', () => { 6 | expect( 7 | mapDescriptionToComment({ 8 | type: 'dimension', 9 | value: '10px', 10 | description: 'Some description about the token', 11 | }), 12 | ).to.eql({ 13 | type: 'dimension', 14 | value: '10px', 15 | description: 'Some description about the token', 16 | comment: 'Some description about the token', 17 | }); 18 | 19 | expect( 20 | mapDescriptionToComment({ 21 | type: 'dimension', 22 | value: '10px', 23 | description: 24 | 'This is the first line.\nThis is the second line.\rThis is the third line.\r\nThis is the fourth line.', 25 | }), 26 | ).to.eql({ 27 | type: 'dimension', 28 | value: '10px', 29 | description: 30 | 'This is the first line.\nThis is the second line.\rThis is the third line.\r\nThis is the fourth line.', 31 | comment: 32 | 'This is the first line. This is the second line. This is the third line. This is the fourth line.', 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /handle-bundled-cjs-deps.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, renameSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | import { globSync } from 'glob'; 4 | import { CJSOnlyDeps } from './rollup.config.mjs'; 5 | 6 | // Because we bundle our CJS deps and we preserveModules, normally it would create 7 | // a dist/node_modules folder for the deps. This is an issue because then the consumer's NodeJS 8 | // resolution algorithm will pull bare import specifiers from this folder rather than their own root 9 | // node_modules folder, even for CJS usage :( 10 | // Therefore, we temporarily move the CJS deps to a different folder, include that 11 | // in the nodeResolve moduleDirectories and move things back after we're done. 12 | async function run() { 13 | renameSync(path.resolve('dist/node_modules'), path.resolve('dist/bundled_CJS_deps')); 14 | const files = await globSync('dist/**/*.js', { nodir: true }); 15 | 16 | files.forEach(file => { 17 | CJSOnlyDeps.forEach(dep => { 18 | const reg = new RegExp(`node_modules/${dep}`, 'g'); 19 | const filePath = path.resolve(file); 20 | const currentFileContents = readFileSync(filePath, 'utf-8'); 21 | const newFileContents = currentFileContents.replace(reg, `bundled_CJS_deps/${dep}`); 22 | writeFileSync(filePath, newFileContents, 'utf-8'); 23 | }); 24 | }); 25 | } 26 | run(); 27 | -------------------------------------------------------------------------------- /src/compose/transformTypography.ts: -------------------------------------------------------------------------------- 1 | import { transformFontWeights } from '../transformFontWeights.js'; 2 | 3 | /** 4 | * Helper: Transforms typography object to typography shorthand for Jetpack Compose 5 | */ 6 | export function transformTypographyForCompose( 7 | value: Record | undefined, 8 | ): string | undefined { 9 | if (value === undefined) { 10 | return value; 11 | } 12 | 13 | /** 14 | * Mapping between https://docs.tokens.studio/available-tokens/typography-tokens 15 | * and https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextStyle 16 | * Unsupported property: 17 | * - paragraphSpacing 18 | */ 19 | const textStylePropertiesMapping = { 20 | fontFamily: 'fontFamily', 21 | fontWeight: 'fontWeight', 22 | lineHeight: 'lineHeight', 23 | fontSize: 'fontSize', 24 | letterSpacing: 'letterSpacing', 25 | paragraphIndent: 'textIndent', 26 | }; 27 | 28 | /** 29 | * Constructs a `TextStyle`, e.g. 30 | * TextStyle( 31 | * fontSize = 16.dp 32 | * ) 33 | */ 34 | return `${Object.entries(value).reduce( 35 | (acc, [propName, val]) => 36 | `${acc}${ 37 | textStylePropertiesMapping[propName] 38 | ? `${propName === 'fontWeight' ? transformFontWeights(val) : val}\n` 39 | : '' 40 | }`, 41 | 'TextStyle(\n', 42 | )})`; 43 | } 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Things to know before contributing: 4 | 5 | ## Tests 6 | 7 | Unit tests should provide 100% coverage. Use: 8 | 9 | ```sh 10 | npm run test 11 | npm run test:unit:coverage 12 | ``` 13 | 14 | To run the tests and view the coverage report, to see which things are untested. 15 | 16 | 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). 17 | 18 | ## Linting 19 | 20 | This checks code quality with ESLint, formatting with Prettier and types with TypeScript. 21 | VSCode extensions for ESLint/Prettier are recommended, but you can always run: 22 | 23 | ```sh 24 | npm run format 25 | ``` 26 | 27 | after doing your work, to fix most issues automatically. 28 | 29 | ## Versioning 30 | 31 | 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. 32 | 33 | ## Contact 34 | 35 | For new ideas, feature requests, issues, bugs, etc., use GitHub issues. 36 | Also, feel free to reach out to us on [Slack](https://join.slack.com/t/tokens-studio/shared_invite/zt-1p8ea3m6t-C163oJcN9g3~YZTKRgo2hg). 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { registerTransforms } from './registerTransforms.js'; 2 | export { transforms } from './registerTransforms.js'; 3 | 4 | export { expandComposites } from './parsers/expand-composites.js'; 5 | export { excludeParentKeys } from './parsers/exclude-parent-keys.js'; 6 | export { addFontStyles } from './parsers/add-font-styles.js'; 7 | export { parseTokens } from './parsers/parse-tokens.js'; 8 | 9 | export { mapDescriptionToComment } from './mapDescriptionToComment.js'; 10 | export { checkAndEvaluateMath } from './checkAndEvaluateMath.js'; 11 | export { transformDimension } from './transformDimension.js'; 12 | export { transformFontWeights } from './transformFontWeights.js'; 13 | export { transformColorModifiers } from './color-modifiers/transformColorModifiers.js'; 14 | export { transformLineHeight } from './transformLineHeight.js'; 15 | 16 | export { transformShadowForCSS } from './css/transformShadow.js'; 17 | export { transformBorderForCSS } from './css/transformBorder.js'; 18 | export { transformTypographyForCSS } from './css/transformTypography.js'; 19 | export { transformHEXRGBaForCSS } from './css/transformHEXRGBa.js'; 20 | export { transformLetterSpacingForCSS } from './css/transformLetterSpacing.js'; 21 | 22 | export { transformTypographyForCompose } from './compose/transformTypography.js'; 23 | 24 | export { permutateThemes } from './permutateThemes.js'; 25 | -------------------------------------------------------------------------------- /src/css/transformShadow.ts: -------------------------------------------------------------------------------- 1 | import { checkAndEvaluateMath } from '../checkAndEvaluateMath.js'; 2 | import { transformDimension } from '../transformDimension.js'; 3 | import { transformHEXRGBaForCSS } from './transformHEXRGBa.js'; 4 | import { isNothing } from '../utils/is-nothing.js'; 5 | 6 | /** 7 | * Helper: Transforms boxShadow object to shadow shorthand 8 | * This currently works fine if every value uses an alias, 9 | * but if any one of these use a raw value, it will not be transformed. 10 | */ 11 | export function transformShadowForCSS( 12 | shadow: Record | undefined | string, 13 | ): string | undefined { 14 | if (typeof shadow !== 'object') { 15 | return shadow; 16 | } 17 | let { x, y, blur, spread } = shadow; 18 | const { color, type } = shadow; 19 | x = transformDimension(checkAndEvaluateMath(x) as number | string | undefined); 20 | y = transformDimension(checkAndEvaluateMath(y) as number | string | undefined); 21 | blur = transformDimension(checkAndEvaluateMath(blur) as number | string | undefined); 22 | spread = transformDimension(checkAndEvaluateMath(spread) as number | string | undefined); 23 | return `${type === 'innerShadow' ? 'inset ' : ''}${isNothing(x) ? 0 : x} ${ 24 | isNothing(y) ? 0 : y 25 | } ${isNothing(blur) ? 0 : blur}${isNothing(spread) ? ' ' : ` ${spread} `}${ 26 | transformHEXRGBaForCSS(color) ?? 'rgba(0, 0, 0, 1)' 27 | }`.trim(); 28 | } 29 | -------------------------------------------------------------------------------- /test/spec/transformFontWeights.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformFontWeights, fontWeightMap } from '../../src/transformFontWeights.js'; 3 | import { runTransformSuite } from '../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformFontWeights as (value: unknown) => unknown); 6 | 7 | describe('transform dimension', () => { 8 | it('transforms fontweight keynames to fontweight numbers', () => { 9 | Object.entries(fontWeightMap).forEach(([keyname, number]) => { 10 | expect(transformFontWeights(keyname)).to.equal(number); 11 | }); 12 | }); 13 | 14 | it('keeps fontweights that are not part of the fontweightmap, as is', () => { 15 | expect(transformFontWeights('300')).to.equal('300'); 16 | expect(transformFontWeights('foo')).to.equal('foo'); 17 | }); 18 | 19 | it('supports case-insensitive input', () => { 20 | expect(transformFontWeights('Light')).to.equal(300); 21 | }); 22 | 23 | it('supports fontWeights with fontStyles inside of them', () => { 24 | expect(transformFontWeights('Light normal')).to.equal(`300 normal`); 25 | expect(transformFontWeights('ExtraBold Italic')).to.equal(`800 italic`); 26 | }); 27 | 28 | it('supports fontWeights with space separators', () => { 29 | expect(transformFontWeights('Extra Bold')).to.equal(800); 30 | expect(transformFontWeights('Ultra Black Italic')).to.equal(`950 italic`); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/TransformOptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SingleBorderToken, 3 | SingleBoxShadowToken, 4 | SingleCompositionToken, 5 | SingleToken, 6 | SingleTypographyToken, 7 | } from '@tokens-studio/types'; 8 | 9 | export type Expandables = 10 | | SingleCompositionToken 11 | | SingleTypographyToken 12 | | SingleBorderToken 13 | | SingleBoxShadowToken; 14 | 15 | export const expandablesAsStringsArr = ['composition', 'typography', 'border', 'boxShadow']; 16 | export type ExpandablesAsStrings = (typeof expandablesAsStringsArr)[number]; 17 | 18 | export type ExpandFilter = (token: T, filePath?: string) => boolean; 19 | 20 | export interface ExpandOptions { 21 | typography?: boolean | ExpandFilter; // default false 22 | border?: boolean | ExpandFilter; // default false 23 | shadow?: boolean | ExpandFilter; // default false 24 | composition?: boolean | ExpandFilter; // default true 25 | } 26 | 27 | export type ColorModifierFormat = 'hex' | 'hsl' | 'lch' | 'p3' | 'srgb'; 28 | 29 | export interface ColorModifierOptions { 30 | format: ColorModifierFormat; 31 | } 32 | 33 | export interface TransformOptions { 34 | addAttributeCTI?: boolean; 35 | casing?: 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant'; 36 | alwaysAddFontStyle?: boolean; 37 | expand?: ExpandOptions | false; 38 | excludeParentKeys?: boolean; 39 | ['ts/color/modifiers']?: ColorModifierOptions; 40 | } 41 | -------------------------------------------------------------------------------- /test/spec/compose/transformTypographyForCompose.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai'; 2 | import { transformTypographyForCompose } from '../../../src/compose/transformTypography.js'; 3 | import { runTransformSuite } from '../../suites/transform-suite.spec.js'; 4 | 5 | runTransformSuite(transformTypographyForCompose as (value: unknown) => unknown); 6 | 7 | describe('transform typography', () => { 8 | it('transforms typography object to typography shorthand', () => { 9 | expect( 10 | transformTypographyForCompose({ 11 | fontWeight: '500', 12 | fontSize: '20px', 13 | lineHeight: '1.5', 14 | fontFamily: 'Arial', 15 | }), 16 | ).to.equal(`TextStyle( 17 | 500 18 | 20px 19 | 1.5 20 | Arial 21 | )`); 22 | }); 23 | 24 | it('transforms typography object to typography shorthand', () => { 25 | expect( 26 | transformTypographyForCompose({ 27 | fontWeight: 'light', 28 | fontSize: '20px', 29 | lineHeight: '1.5', 30 | fontFamily: 'Arial', 31 | }), 32 | ).to.equal(`TextStyle( 33 | 300 34 | 20px 35 | 1.5 36 | Arial 37 | )`); 38 | }); 39 | 40 | it('transforms ignores unknown properties in typography object and transforms to empty string', () => { 41 | expect( 42 | transformTypographyForCompose({ 43 | fontWeight: 'light', 44 | foo: '20px', 45 | lineHeight: '1.5', 46 | fontFamily: 'Arial', 47 | }), 48 | ).to.equal(`TextStyle( 49 | 300 50 | 1.5 51 | Arial 52 | )`); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/transformFontWeights.ts: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping 2 | export const fontWeightMap = { 3 | hairline: 100, 4 | thin: 100, 5 | extralight: 200, 6 | ultralight: 200, 7 | extraleicht: 200, 8 | light: 300, 9 | leicht: 300, 10 | normal: 400, 11 | regular: 400, 12 | buch: 400, 13 | medium: 500, 14 | kraeftig: 500, 15 | kräftig: 500, 16 | semibold: 600, 17 | demibold: 600, 18 | halbfett: 600, 19 | bold: 700, 20 | dreiviertelfett: 700, 21 | extrabold: 800, 22 | ultrabold: 800, 23 | fett: 800, 24 | black: 900, 25 | heavy: 900, 26 | super: 900, 27 | extrafett: 900, 28 | ultra: 950, 29 | ultrablack: 950, 30 | extrablack: 950, 31 | }; 32 | 33 | export const fontStyles = ['italic', 'oblique', 'normal']; 34 | export const fontWeightReg = new RegExp( 35 | `(?.+?)\\s?(?