├── .dependabot └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .npmrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── bin ├── chromatic ├── chromatic.js ├── chromatic.js.map ├── chromatic.map ├── examples │ ├── advanced │ │ └── tokens.yaml │ └── basic │ │ └── tokens.yaml ├── package.json └── templates │ ├── css.hbs │ ├── data-dump.hbs │ ├── html-colors.hbs │ ├── html-file.hbs │ ├── json.hbs │ ├── sass.hbs │ └── yaml.hbs ├── config └── rollup.config.js ├── docs ├── errors │ └── tokens-as-array.md ├── functions.md ├── guide.md ├── reference.md └── token-value.md ├── examples ├── advanced │ └── tokens.yaml └── basic │ └── tokens.yaml ├── package-lock.json ├── package.json ├── scripts ├── build.sh ├── clean.sh └── test.sh ├── src ├── chromatic-cli.ts ├── chromatic.ts ├── color-functions.ts ├── default-formatters.ts ├── errors.ts ├── formats-generic.ts ├── formats-styleguide.ts ├── formats-web.ts ├── formats.ts ├── templates │ ├── css.hbs │ ├── data-dump.hbs │ ├── html-colors.hbs │ ├── html-file.hbs │ ├── json.hbs │ ├── sass.hbs │ └── yaml.hbs ├── terminal.ts ├── utils.ts ├── value-parser.ts └── value.ts ├── test ├── __snapshots__ │ └── token.test.js.snap ├── token.test.js └── tokens │ ├── aliases.yaml │ ├── angle.yaml │ ├── array.yaml │ ├── basic-example │ ├── base-colors.yaml │ ├── dark-theme.yaml │ └── tokens.yaml │ ├── colors.yaml │ ├── errors.yaml │ ├── expressions.yaml │ ├── invalid-token-name.yaml │ ├── length.yaml │ ├── metadata.yaml │ ├── no-tokens.yaml │ ├── simple.yaml │ ├── theme-propagation.yaml │ ├── theme.yaml │ └── token-array.yaml └── tsconfig.json /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | update_configs: 4 | # Keep package.json (& lockfiles) up to date 5 | # batching pull requests weekly 6 | - package_manager: 'javascript' 7 | directory: '/' 8 | update_schedule: 'weekly' 9 | automerged_updates: 10 | - match: 11 | # Supported dependency types: 12 | # - "development" 13 | # - "production" 14 | # - "all" 15 | dependency_type: 'all' 16 | update_type: 'semver:minor' 17 | version_requirement_updates: 'increase_versions' 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Third party 2 | **/node_modules 3 | 4 | # Build products 5 | build/ 6 | coverage/ 7 | dist/ 8 | stage/ 9 | bin/ 10 | 11 | # Config files 12 | config/ 13 | *.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | root: true, 4 | // Use the Typescript parser: 5 | parser: '@typescript-eslint/parser', 6 | extends: [ 7 | // Uses the recommended rules for Typescript 8 | 'plugin:@typescript-eslint/recommended', 9 | // Disable rules that conflict with prettier 10 | // See https://prettier.io/docs/en/integrating-with-linters.html 11 | 'plugin:prettier/recommended', 12 | ], 13 | parserOptions: { 14 | project: './tsconfig.json', 15 | // Configure the parser with the tsconfig file in the root project 16 | // (not the one in the local workspace) 17 | // tsconfigRootDir: path.resolve(__dirname, './src/'), 18 | // Allows for the parsing of modern ECMAScript features 19 | ecmaVersion: 2018, 20 | // Allows for the use of module imports 21 | sourceType: 'module', 22 | // ecmaFeatures: { 23 | // jsx: true, // Allows for the parsing of JSX 24 | // }, 25 | }, 26 | env: { 27 | es6: true, 28 | node: true, 29 | }, 30 | rules: { 31 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 32 | '@typescript-eslint/no-explicit-any': ['off'], 33 | '@typescript-eslint/no-var-requires': ['off'], 34 | '@typescript-eslint/no-use-before-define': ['off'], 35 | 36 | 'indent': 'off', 37 | 'no-use-before-define': [ 38 | 'off', 39 | { 40 | functions: false, 41 | classes: false, 42 | }, 43 | ], 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Enforce Unix newlines 5 | *.css text eol=lf 6 | *.html text eol=lf 7 | *.mjs text eol=lf 8 | *.js text eol=lf 9 | *.ts text eol=lf 10 | *.json text eol=lf 11 | *.md text eol=lf 12 | *.mjs text eol=lf 13 | *.rb text eol=lf 14 | *.scss text eol=lf 15 | *.svg text eol=lf 16 | *.txt text eol=lf 17 | *.xml text eol=lf 18 | *.yml text eol=lf 19 | *.yaml text eol=lf 20 | 21 | 22 | # Don't diff or textually merge source maps 23 | *.map binary 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | stage/ 4 | coverage/ 5 | .rpt2_cache/ 6 | 7 | # Secret files 8 | *secret.js 9 | 10 | # Log files 11 | npm-debug.log 12 | 13 | # Dependency directory 14 | node_modules 15 | 16 | # OS generated files 17 | .DS_Store 18 | ._* 19 | .Spotlight-V100 20 | .Trashes 21 | ehthumbs.db 22 | Thumbs.db 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | loglevel="silent" -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Style Guide", 11 | "skipFiles": ["/**"], 12 | "preLaunchTask": "npm: build", 13 | "program": "${workspaceFolder}/bin/chromatic", 14 | "args": [ 15 | "./examples/advanced/tokens.yaml", 16 | // "test/tokens/basic-example/tokens.yaml", 17 | "--format", 18 | "html", // html, sass 19 | "--verbose", 20 | "--ignore-errors", 21 | "-o", 22 | "build/style-guide.html" 23 | ] 24 | }, 25 | { 26 | "type": "node", 27 | "request": "launch", 28 | "name": "Basic", 29 | "skipFiles": ["/**"], 30 | "program": "${workspaceFolder}/bin/chromatic", 31 | "args": [ 32 | // "./test/tokens/../../examples/advanced/tokens.yaml", 33 | "./test/tokens/colors.yaml", 34 | "--format", 35 | "yaml", 36 | "--header=''", 37 | "--verbose", 38 | "--ignore-errors" 39 | ] 40 | }, 41 | { 42 | "type": "node", 43 | "request": "launch", 44 | "name": "Test", 45 | "skipFiles": ["/**"], 46 | "program": "${workspaceFolder}/bin/chromatic", 47 | "args": ["test/tokens/length.yaml", "--verbose", "--ignore-errors"], 48 | "env": { 49 | // "TEST": "true" 50 | } 51 | }, 52 | { 53 | "type": "node", 54 | "request": "launch", 55 | "name": "Readme", 56 | "skipFiles": ["/**"], 57 | "program": "${workspaceFolder}/bin/chromatic", 58 | "args": ["test/tokens/readme.yaml", "--format='sass'", "--verbose"] 59 | }, 60 | { 61 | "type": "node", 62 | "request": "launch", 63 | "name": "Onboarding", 64 | "skipFiles": ["/**"], 65 | "program": "${workspaceFolder}/bin/chromatic", 66 | "args": ["--verbose"] 67 | }, 68 | { 69 | "type": "node", 70 | "request": "launch", 71 | "name": "Piping", 72 | "skipFiles": ["/**"], 73 | "program": "${workspaceFolder}/bin/chromatic", 74 | "args": ["--verbose"] 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["cosmiconfig", "luma"], 3 | "editor.formatOnSave": true, 4 | "liveServer.settings.port": 5501, 5 | "scss.format.enable": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: build", 10 | "detail": "bash ./scripts/build.sh dev" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2019-12-01 2 | 3 | ### Features 4 | 5 | - feat(scale): Added typographic scale. `scale(12pt)` will return an array of 6 | font-sizes. See 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arno@arno.org. All complaints will be 59 | reviewed and investigated and will result in a response that is deemed necessary 60 | and appropriate to the circumstances. The project team is obligated to maintain 61 | confidentiality with regard to the reporter of an incident. Further details of 62 | specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Project 2 | 3 | There are many ways you can get involved with the project. Contributing to an 4 | open source project is fun and rewarding. 5 | 6 | ## Funding 7 | 8 | Consider donating to project development via 9 | [Patreon](https://patreon.com/arnog) (recurring donation) or 10 | [PayPal](https://www.paypal.me/arnogourdol) (one time donation). 11 | 12 | Encourage the business partners in your organization to provide financial 13 | support of open source projects. 14 | 15 | Funds go to general development, support, and infrastructure costs. 16 | 17 | We welcome both individual and corporate sponsors. In addition to Patreon and 18 | PayPal, we can also accept short-term development contracts for specific 19 | features or maintenance of the project. 20 | 21 | ## Contributing Issues 22 | 23 | If you're running into some problems or something doesn't behave the way you 24 | think it should, please file an issue in GitHub. 25 | 26 | Before filing something, have a look at the existing issues. It's better to 27 | avoid filing duplicates. You can add a comment to an existing issue if you'd 28 | like. 29 | 30 | ### Can I help fix a bug? 31 | 32 | Sure! Have a look at the issue report, and make sure no one is already working 33 | on it. If the issue is assigned to someone, they're on it! Otherwise, add a 34 | comment in the issue indicating you'd like to work on resolving the issue and go 35 | for it! 36 | 37 | Whether you have a fix for an issue, some improved test cases, or a brand new 38 | feature, we welcome contributions in the form of pull requests. 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 - present Arno Gourdol. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chromatic 2 | 3 | A tool to help manage design systems by generating platform-specific files from 4 | a source file describing design tokens. 5 | 6 | ### Expressive Design Tokens 7 | 8 | Tokens can contain rich expressions in a natural syntax, including arithmetic 9 | operations, units (`12px`), function (`rgb()`, `mix()`, `saturate()`...) and 10 | references to other tokens. 11 | 12 | ```yaml 13 | tokens: 14 | primary-hue: '210deg' 15 | primary: 'hsl({primary-hue}, 100%, 40%)' 16 | primary-dark: 'darken({primary}, 20%)' 17 | 18 | line-height: '18pt + 5px' 19 | ``` 20 | 21 | ### Themes 22 | 23 | Each token can have a theme variant, such as dark/light, or compact/cozy 24 | layouts. The necessary output artifacts are generated automatically. 25 | 26 | ```yaml 27 | tokens: 28 | cta-button-background: 29 | value: 30 | dark: '#004082' 31 | light: '#0066ce' 32 | ``` 33 | 34 | ### Zero-conf 35 | 36 | Get going quickly. A simple **token file** written YAML or JSON file is all you 37 | need. 38 | 39 | But Chromatic is also customizable when you need to. You can write or modify the 40 | format of the output files to suit your needs. 41 | 42 | Chromatic is also available as an API that can be invoked from a build system. 43 | 44 | ### Multi-platform 45 | 46 | From a single token file, generate platform specific artifacts: 47 | 48 | - for the web (Sass, CSS) 49 | - for iOS (JSON, plist) 50 | - for Android (XML) 51 | 52 | Chromatic can also generate a style guide as a HTML file. 53 | 54 | ## Getting started with Chromatic 55 | 56 | ```shell 57 | $ npm install -g @arnog/chromatic 58 | ``` 59 | 60 | To create a directory with an example: 61 | 62 | ```shell 63 | $ chromatic example ./test 64 | $ chromatic ./test -o tokens.scss 65 | $ chromatic ./test -o tokens.html 66 | ``` 67 | 68 | Or writing your own token file: 69 | 70 | ```yaml 71 | # tokens.yaml 72 | tokens: 73 | background: '#f1f1f1' 74 | body-color: '#333' 75 | ``` 76 | 77 | ```shell 78 | $ chromatic tokens.yaml -o tokens.scss 79 | ``` 80 | 81 | ```scss 82 | $background: #f1f1f1 !default; 83 | $body-color: #333 !default; 84 | ``` 85 | 86 | Now, let's create a dark theme: 87 | 88 | ```yaml 89 | # tokens-dark.yaml 90 | theme: dark 91 | tokens: 92 | background: '#222' 93 | body-color: '#a0a0a0' 94 | ``` 95 | 96 | ```yaml 97 | # tokens.yaml 98 | import: ./tokens-dark.yaml 99 | tokens: 100 | background: '#f1f1f1' 101 | body-color: '#333' 102 | ``` 103 | 104 | ```shell 105 | $ chromatic tokens.yaml -o tokens.scss 106 | ``` 107 | 108 | ```css 109 | :root { 110 | --background: #f1f1f1; 111 | --body-color: #333; 112 | } 113 | body[data-theme='dark'] { 114 | --background: #222; 115 | --body-color: #a0a0a0; 116 | } 117 | ``` 118 | -------------------------------------------------------------------------------- /bin/examples/advanced/tokens.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | red: 'scale(hsl(4deg, 90%, 50%))' # #F21C0D Red RYB 3 | orange: 'scale(hsl(30deg, 100%, 60%))' # #FF9933 Indian Saffron / Deep Saffron 4 | brown: 'scale(hsl(34deg, 30%, 40%))' 5 | yellow: 'scale(hsl(46deg, 100%, 60%))' # #FFCF33 Peach Cobbler / Sunglow 6 | lime: 'scale(hsl(90deg, 79%, 39%))' # #63B215 Kelly Green 7 | green: 'scale(hsl(130deg, 70%, 43%))' # #17CF36 Vivid Malachite 8 | teal: 'scale(hsl(180deg, 80%, 45%))' # #17CFCF Dark Turquoise 9 | cyan: 'scale(hsl(199deg, 85%, 50%))' 10 | blue: 'scale(hsl(210deg, 90%, 50%))' # #0D80F2 Tropical Thread / Azure 11 | indigo: 'scale(hsl(260deg, 60%, 50%))' # #6633CC Strong Violet / Iris 12 | purple: 'scale(hsl(280deg, 80%, 50%))' # #A219E6 Purple X11 13 | magenta: 'scale(hsl(330deg, 80%, 60%))' # #EB4799 Raspberry Pink 14 | 15 | border-radius: '4px' 16 | 17 | line-height: '18pt + 4px' 18 | 19 | # Defining an angle using the 'deg' unit 20 | primary-hue: 21 | value: 22 | _: '210deg' 23 | dark: '200deg' 24 | 25 | # A token expression can reference another token by enclosing it in "{}" 26 | # Functions can be used to perform more complex calculations 27 | # The 'hsl()' function will return a color based on a hue, saturation and lightness 28 | # Other color functions include "rgb()", "hsv()", "hwb()" and "lab()" 29 | primary: 'hsl({primary-hue}, 100%, 40%)' 30 | cta-button-background: '{primary}' 31 | cta-button-background-active: 'darken({cta-button-background}, 20%)' 32 | cta-button-background-hover: 'darken({cta-button-background}, 10%)' 33 | 34 | # Related tokens can be grouped together 35 | semantic: 36 | # Color scales (darker or lighter variant of a base color) are 37 | # created automatically and can be referenced by adding a "-" and three 38 | # digits after a token name. 39 | error: '{red-600}' 40 | warning: 41 | value: 42 | _: '{orange-400}' 43 | dark: '{orange-500}' 44 | comment: 'Use for problems that do not prevent the task to complete' 45 | success: '{green-600}' 46 | 47 | color-blind: 48 | tritan-1: '#FAFF00' 49 | tritan-2: '#FDF4F8' 50 | protan-1: '#3B7398' 51 | protan-2: '#D81B60' 52 | deuteran-1: '#32F3D9' 53 | deuteran-2: '#F1CFEC' 54 | groups: 55 | semantic: 56 | comment: 'These color values are used to convey a meaning' 57 | remarks: 58 | '**For more information** about the hidden meaning of semantic colors 59 | [read this](https://en.wikipedia.org/wiki/Color_symbolism)' 60 | -------------------------------------------------------------------------------- /bin/examples/basic/tokens.yaml: -------------------------------------------------------------------------------- 1 | # This token file is using the .yaml format, but tokens files can also 2 | # be authored using JSON5 (JSON with comments) 3 | 4 | # A token file should have a mandatory "tokens" key 5 | tokens: 6 | # Token names can contain letters, digits, '_' or '-'. 7 | # The value of a token can be a literal (constant) or an expression. 8 | # Some literals have units, here 'px' for pixels. 9 | border-radius: '4px' 10 | 11 | # Here we define a length using an arithmetic expression. 12 | # The syntax of token expressions follows closely the CSS level-4 syntax. 13 | line-height: '18pt + 4px' 14 | 15 | # Defining an angle using the 'deg' unit 16 | primary-hue: '200deg' 17 | 18 | # A token expression can reference another token by enclosing it in "{}" 19 | # Functions can be used to perform more complex calculations 20 | # The 'hsl()' function will return a color based on a hue, saturation and lightness 21 | # Other color functions include "rgb()", "hsv()", "hwb()" and "lab()" 22 | primary: 'hsl({primary-hue}, 100%, 40%)' 23 | cta-button-background: '{primary}' 24 | cta-button-background-active: 'darken({cta-button-background}, 20%)' 25 | cta-button-background-hover: 'darken({cta-button-background}, 10%)' 26 | 27 | red: 'hsl(348, 86%, 61%)' 28 | orange: 'hsl(14, 100%, 53%)' 29 | green: 'hsl(141, 53%, 53%)' 30 | 31 | # Related tokens can be grouped together 32 | semantic: 33 | # Color scales (darker or lighter variant of a base color) are 34 | # created automatically and can be referenced by adding a "-" and three 35 | # digits after a token name. 36 | error: '{red-600}' 37 | warning: '{orange-400}' 38 | success: '{green-600}' 39 | 40 | groups: 41 | semantic: 42 | comment: 'These color values are used to convey a meaning' 43 | remarks: '**For more information** about the hidden meaning of semantic colors [read this](https://en.wikipedia.org/wiki/Color_symbolism)' 44 | -------------------------------------------------------------------------------- /bin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ui-js/chromatic", 3 | "version": "0.8.3", 4 | "description": "A build system for managing cross-platform design systems using design tokens.", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "keywords": [ 9 | "design tokens", 10 | "design system", 11 | "ui", 12 | "theo", 13 | "build system", 14 | "css", 15 | "sass", 16 | "less", 17 | "stylus", 18 | "iOS", 19 | "Android", 20 | "style guide" 21 | ], 22 | "license": "MIT", 23 | "files": [ 24 | "bin/**" 25 | ], 26 | "engines": { 27 | "node": ">=16.14.2" 28 | }, 29 | "devDependencies": { 30 | "@cortex-js/prettier-config": "^1.1.1", 31 | "@rollup/plugin-node-resolve": "^13.1.3", 32 | "@typescript-eslint/eslint-plugin": "^5.17.0", 33 | "@typescript-eslint/parser": "^5.17.0", 34 | "eslint": "^8.12.0", 35 | "eslint-config-prettier": "^8.5.0", 36 | "eslint-plugin-prettier": "^4.0.0", 37 | "husky": "^7.0.4", 38 | "jest": "^27.5.1", 39 | "lint-staged": "^12.3.7", 40 | "prettier": "^2.6.2", 41 | "rollup": "^2.70.1", 42 | "rollup-plugin-commonjs": "^10.1.0", 43 | "rollup-plugin-copy": "^3.4.0", 44 | "rollup-plugin-eslint": "^7.0.0", 45 | "rollup-plugin-terser": "^7.0.2", 46 | "rollup-plugin-typescript2": "^0.31.2", 47 | "typescript": "^4.6.3" 48 | }, 49 | "dependencies": { 50 | "chalk": "4.1.2", 51 | "chokidar": "^3.5.3", 52 | "chroma-js": "^2.4.2", 53 | "ci-info": "^3.3.0", 54 | "color": "^4.2.1", 55 | "color-name": "^1.1.4", 56 | "cosmiconfig": "^7.0.1", 57 | "fs-extra": "^10.0.1", 58 | "glob": "^7.2.0", 59 | "handlebars": "^4.7.7", 60 | "highlight.js": "^11.5.0", 61 | "json5": "^2.2.1", 62 | "marked": "^4.0.12", 63 | "please-upgrade-node": "^3.2.0", 64 | "resolve-from": "^5.0.0", 65 | "string-similarity": "^4.0.4", 66 | "update-notifier": "^5.1.0", 67 | "yaml": "^1.10.2", 68 | "yargs": "^17.4.0" 69 | }, 70 | "repository": { 71 | "type": "git", 72 | "url": "https://github.com/ui-js/chromatic.git" 73 | }, 74 | "bin": { 75 | "chromatic": "./bin/chromatic" 76 | }, 77 | "scripts": { 78 | "build": "bash ./scripts/build.sh dev", 79 | "build:prod": "bash ./scripts/build.sh production", 80 | "chromatic": "node ./bin/chromatic", 81 | "clean": "bash ./scripts/clean.sh", 82 | "coverage": "bash ./scripts/test.sh coverage", 83 | "lint": "prettier --ignore-path ./.prettierignore --write \"**/*.{ts,js,css,md,yml,json}\" \"!vendor/**\"", 84 | "snapshot": "bash ./scripts/test.sh snapshot", 85 | "test": "bash ./scripts/test.sh", 86 | "watch": "rollup --config ./config/rollup.config.js --watch" 87 | }, 88 | "prettier": "@cortex-js/prettier-config", 89 | "husky": { 90 | "hooks": { 91 | "pre-commit": "lint-staged" 92 | } 93 | }, 94 | "lint-staged": { 95 | "**/*.ts": [ 96 | "eslint --fix", 97 | "git add" 98 | ], 99 | "*.{js,css,json,md}": [ 100 | "prettier --write", 101 | "git add" 102 | ] 103 | }, 104 | "jest": { 105 | "roots": [ 106 | "/test", 107 | "/bin" 108 | ], 109 | "coverageReporters": [ 110 | "lcov" 111 | ], 112 | "coverageDirectory": "./coverage" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /bin/templates/css.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader}} 2 | {{#each themes}} 3 | {{#if theme}} 4 | 5 | {{! Output css classes corresponding to the property... }} 6 | {{#if isDefaultTheme}} 7 | {{#each tokens}} 8 | .{{sanitizeCssPropertyName tokenName}} 9 | { color: 10 | {{cssValue tokenValue}}; } 11 | {{/each}} 12 | {{/if}} 13 | 14 | {{! This is a themed tokens (multiple definitions }} 15 | {{#if isDefaultTheme}} 16 | {{! The current theme is the default theme ('_') }} 17 | :root { 18 | {{else}} 19 | {{! The current theme is a custom }} 20 | body[data-theme="{{theme}}"] { 21 | {{/if}} 22 | {{#each tokens}} 23 | {{! Output a custom property }} 24 | --{{sanitizeCssPropertyName tokenId}}: 25 | {{{cssValue tokenValue}}}; 26 | {{/each}} 27 | }; 28 | 29 | {{else}} 30 | 31 | {{! Output all tokens that have a single definition (no theme) as a CSS variable}} 32 | {{#each tokens}} 33 | {{#if tokenDefinition.comment}} 34 | {{comment tokenDefinition.comment '//'}} 35 | {{/if}} 36 | .{{sanitizeCssPropertyName tokenName}} 37 | { color: var(--{{sanitizeCssPropertyName tokenName}}); } 38 | {{/each}} 39 | 40 | {{/if}} 41 | {{/each}} -------------------------------------------------------------------------------- /bin/templates/data-dump.hbs: -------------------------------------------------------------------------------- 1 | filepath: "{{filepath}}" 2 | fileHeader: "{{fileHeader}}" 3 | themes: 4 | {{#each themes}} 5 | - theme: "{{theme}}": 6 | isDefaultTheme: {{isDefaultTheme}} 7 | tokens: 8 | {{#each tokens}} 9 | - tokenId: "{{tokenId}}" 10 | tokenName: "{{tokenName}}" 11 | tokenDefinition: 12 | comment: {{tokenDefinition.comment}} 13 | remarks: {{tokenDefinition.remarks}} 14 | deprecated: {{tokenDefinition.deprecated}} 15 | tokenValue: "{{cssValue tokenValue}}" 16 | {{/each}} 17 | {{/each}} 18 | groups: 19 | {{#each groups}} 20 | - groupId: "{{groupId}}" 21 | groupInfo: 22 | name: "{{groupInfo.name}}" 23 | comment: "{{groupInfo.comment}}" 24 | remarks: "{{groupInfo.remarks}}" 25 | tokens: 26 | {{#each tokens}} 27 | - tokenId: "{{tokenId}}" 28 | tokenDefinition: 29 | comment: {{tokenDefinition.comment}} 30 | remarks: {{tokenDefinition.remarks}} 31 | deprecated: {{tokenDefinition.deprecated}} 32 | themes: 33 | {{#each themes}} 34 | - theme: "{{theme}}" 35 | tokenName: "{{tokenName}}" 36 | tokenValue: "{{cssValue tokenValue}}" 37 | {{/each}} 38 | {{/each}} 39 | {{/each}} 40 | -------------------------------------------------------------------------------- /bin/templates/html-colors.hbs: -------------------------------------------------------------------------------- 1 | {{#if group}}

{{ group }}

{{/if}} 2 | 3 |
4 | {{#each colorRamps}} 5 |
6 |

{{name}}

7 |
8 | {{#each values}} 9 |
10 |

{{name}}

11 | {{#if deltaE }}{{/if}} 12 |

{{css}}

13 | {{#if deltaE }}δE {{deltaE}}{{/if}} 14 |
15 | {{/each}} 16 |
17 |

{{source}}

18 |
19 | {{/each}} 20 |
21 | 22 |
23 | {{#each colors}} 24 |
25 |
26 |
27 |

{{name}}

28 |

{{css}}

29 |
30 |
31 | {{#if source}} 32 |

{{source}}

33 | {{/if}} 34 | {{#if comment}} 35 |

{{{comment}}}

36 | {{/if}} 37 | {{!--
    38 |
  • 39 | 40 | 41 |
  • 42 |
  • 43 | 44 | 45 |
  • 46 |
  • 47 | 48 | 49 |
  • 50 |
--}} 51 | {{#if similarColors}} 52 |
53 | {{#if similarColors.normal }} 54 |

Similar Colors

55 |
    56 | {{#each similarColors.normal }} 57 |
  • 58 | 59 | {{name}} 60 | 61 | δE {{deltaE}} 62 |
  • 63 | {{/each}} 64 |
65 | {{/if}} 66 | {{!-- {{#if similarColors.protanopia }} 67 |

Similar Colors for People with Protanopia

68 |
    69 | {{#each similarColors.protanopia }} 70 |
  • 71 | 72 | {{name}} 73 | 74 | δE {{deltaE}} 75 |
  • 76 | {{/each}} 77 |
78 | {{/if}} 79 | {{#if similarColors.deuteranopia }} 80 |

Similar Colors for People with Deuteranopia

81 |
    82 | {{#each similarColors.deuteranopia }} 83 |
  • 84 | 85 | {{name}} 86 | 87 | δE {{deltaE}} 88 |
  • 89 | {{/each}} 90 |
91 | {{/if}} 92 | {{#if similarColors.tritanopia }} 93 |

Similar Colors for People with Tritanopia

94 |
    95 | {{#each similarColors.tritanopia }} 96 |
  • 97 | 98 | {{name}} 99 | 100 | δE {{deltaE}} 101 |
  • 102 | {{/each}} 103 |
104 | {{/if}} --}} 105 | {{#if similarColors.colorDeficient }} 106 |

Similar Colors for People with Color Deficiency

107 |
    108 | {{#each similarColors.colorDeficient }} 109 |
  • 110 | 111 | {{name}} 112 | 113 |
  • 114 | {{/each}} 115 |
116 | {{/if}} 117 |
118 | {{/if}} 119 |
120 | {{/each}} 121 |
-------------------------------------------------------------------------------- /bin/templates/html-file.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{#if header}} 4 | 5 | {{/if}} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{!-- 17 | --}} 18 | 19 | 20 | 269 | 270 | 271 | 272 | 273 | {{#if color-section }} 274 |
275 |

Colors

276 | {{{ color-section }}} 277 |
278 | {{/if }} 279 | 280 | 281 | 282 | 283 | 287 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /bin/templates/json.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader}} 2 | 3 | { 4 | {{#remove-last-comma}} 5 | {{#each groups}} 6 | {{#if groupId }} 7 | 8 | /* 9 | * {{ groupId }} 10 | */ 11 | 12 | {{/if}} 13 | {{comment groupInfo.comment '// '}} 14 | {{#each tokens}} 15 | {{#if tokenDefinition.comment}} 16 | {{comment tokenDefinition.comment '// '}} 17 | {{/if}} 18 | {{#each themes }} 19 | "{{tokenName}}": "{{{cssValue tokenValue}}}", 20 | {{/each}} 21 | {{/each}} 22 | {{/each}} 23 | {{/remove-last-comma}} 24 | } -------------------------------------------------------------------------------- /bin/templates/sass.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader}} 2 | {{#each themes}} 3 | {{#if theme}} 4 | 5 | {{! Output Sass properties pointing to the CSS custom property }} 6 | {{#if isDefaultTheme}} 7 | {{#each tokens}} 8 | ${{sanitizeCssPropertyName tokenName}}: var(--{{sanitizeCssPropertyName 9 | tokenName 10 | }}) !default; 11 | {{/each}} 12 | {{/if}} 13 | 14 | {{! This is a themed tokens (multiple definitions }} 15 | {{#if isDefaultTheme}} 16 | {{! The current theme is the default theme ('_') }} 17 | :root { 18 | {{else}} 19 | {{! The current theme is a custom }} 20 | body[data-theme="{{theme}}"] { 21 | {{/if}} 22 | {{#each tokens}} 23 | {{! Output a custom property }} 24 | --{{sanitizeCssPropertyName tokenName}}: {{{cssValue tokenValue}}}; 25 | {{/each}} 26 | }; 27 | 28 | {{else}} 29 | 30 | {{! Output all tokens that have a single definition (no theme) as a Sass variable}} 31 | {{#each tokens}} 32 | {{#if tokenDefinition.comment}} 33 | {{comment tokenDefinition.comment '//'}} 34 | {{/if}} 35 | ${{sanitizeCssPropertyName tokenName}}: {{{cssValue tokenValue}}} !default; 36 | {{/each}} 37 | 38 | 39 | {{/if}} 40 | {{/each}} 41 | 42 | {{#each colorRamps}} 43 | {{this}} 44 | ${{sanitizeCssPropertyName name}}: {{{cssValue value}}} 45 | {{/each}} 46 | -------------------------------------------------------------------------------- /bin/templates/yaml.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader '# '}} 2 | 3 | {{#each groups}} 4 | {{#if groupId }} 5 | 6 | # 7 | # {{ groupId }} 8 | # 9 | {{/if}} 10 | {{comment groupInfo.comment '# '}} 11 | {{#each tokens}} 12 | {{#if tokenDefinition.comment}} 13 | {{comment tokenDefinition.comment '# '}} 14 | {{/if}} 15 | {{#each themes }} 16 | {{tokenName}}: "{{{cssValue tokenValue}}}" 17 | {{/each}} 18 | {{/each}} 19 | {{/each}} 20 | -------------------------------------------------------------------------------- /config/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import { eslint } from 'rollup-plugin-eslint'; 5 | import pkg from '.././package.json'; 6 | import copy from 'rollup-plugin-copy'; 7 | 8 | process.env.BUILD = process.env.BUILD || 'development'; 9 | const PRODUCTION = process.env.BUILD === 'production'; 10 | const BUILD_ID = 11 | Date.now().toString(36).slice(-2) + 12 | Math.floor(Math.random() * 0x186a0).toString(36); 13 | 14 | const TYPESCRIPT_OPTIONS = { 15 | typescript: require('typescript'), 16 | // objectHashIgnoreUnknownHack: true, 17 | clean: PRODUCTION, 18 | tsconfigOverride: { 19 | compilerOptions: { 20 | declaration: false, 21 | }, 22 | }, 23 | }; 24 | 25 | const TERSER_OPTIONS = { 26 | compress: { 27 | drop_console: false, 28 | drop_debugger: true, 29 | ecma: 8, // Use "5" to support older browsers 30 | module: true, 31 | warnings: true, 32 | passes: 2, 33 | global_defs: { 34 | ENV: JSON.stringify(process.env.BUILD), 35 | VERSION: JSON.stringify(pkg.version || '0.0'), 36 | BUILD_ID: JSON.stringify(BUILD_ID), 37 | }, 38 | }, 39 | }; 40 | export default [ 41 | { 42 | input: 'src/chromatic-cli.ts', 43 | output: { 44 | file: 'bin/chromatic', 45 | format: 'cjs', 46 | banner: '#!/usr/bin/env node', 47 | sourcemap: !PRODUCTION, 48 | }, 49 | plugins: [ 50 | resolve({ 51 | preferBuiltins: true, 52 | }), 53 | PRODUCTION && eslint(), 54 | typescript(TYPESCRIPT_OPTIONS), 55 | PRODUCTION && terser(TERSER_OPTIONS), 56 | copy({ 57 | targets: [ 58 | { src: 'src/templates', dest: 'bin' }, 59 | { src: 'examples', dest: 'bin' }, 60 | { src: 'package.json', dest: 'bin' }, 61 | ], 62 | }), 63 | ], 64 | watch: { 65 | clearScreen: true, 66 | exclude: 'node_modules/**', 67 | include: ['src/**', 'examples/**'], 68 | }, 69 | }, 70 | { 71 | input: 'src/chromatic.ts', 72 | output: { 73 | file: 'bin/chromatic.js', 74 | format: 'cjs', 75 | sourcemap: !PRODUCTION, 76 | }, 77 | plugins: [ 78 | resolve({ 79 | preferBuiltins: true, 80 | }), 81 | PRODUCTION && eslint(), 82 | typescript(TYPESCRIPT_OPTIONS), 83 | PRODUCTION && terser(TERSER_OPTIONS), 84 | ], 85 | watch: { 86 | clearScreen: false, 87 | exclude: ['node_modules/**'], 88 | }, 89 | }, 90 | ]; 91 | 92 | /* 93 | amd – Asynchronous Module Definition, used with module loaders like RequireJS 94 | cjs – CommonJS, suitable for Node and other bundlers 95 | esm – Keep the bundle as an ES module file, suitable for other bundlers and inclusion as a 287 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /src/templates/json.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader}} 2 | 3 | { 4 | {{#remove-last-comma}} 5 | {{#each groups}} 6 | {{#if groupId }} 7 | 8 | /* 9 | * {{ groupId }} 10 | */ 11 | 12 | {{/if}} 13 | {{comment groupInfo.comment '// '}} 14 | {{#each tokens}} 15 | {{#if tokenDefinition.comment}} 16 | {{comment tokenDefinition.comment '// '}} 17 | {{/if}} 18 | {{#each themes }} 19 | "{{tokenName}}": "{{{cssValue tokenValue}}}", 20 | {{/each}} 21 | {{/each}} 22 | {{/each}} 23 | {{/remove-last-comma}} 24 | } -------------------------------------------------------------------------------- /src/templates/sass.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader}} 2 | {{#each themes}} 3 | {{#if theme}} 4 | 5 | {{! Output Sass properties pointing to the CSS custom property }} 6 | {{#if isDefaultTheme}} 7 | {{#each tokens}} 8 | ${{sanitizeCssPropertyName tokenName}}: var(--{{sanitizeCssPropertyName 9 | tokenName 10 | }}) !default; 11 | {{/each}} 12 | {{/if}} 13 | 14 | {{! This is a themed tokens (multiple definitions }} 15 | {{#if isDefaultTheme}} 16 | {{! The current theme is the default theme ('_') }} 17 | :root { 18 | {{else}} 19 | {{! The current theme is a custom }} 20 | body[data-theme="{{theme}}"] { 21 | {{/if}} 22 | {{#each tokens}} 23 | {{! Output a custom property }} 24 | --{{sanitizeCssPropertyName tokenName}}: {{{cssValue tokenValue}}}; 25 | {{/each}} 26 | }; 27 | 28 | {{else}} 29 | 30 | {{! Output all tokens that have a single definition (no theme) as a Sass variable}} 31 | {{#each tokens}} 32 | {{#if tokenDefinition.comment}} 33 | {{comment tokenDefinition.comment '//'}} 34 | {{/if}} 35 | ${{sanitizeCssPropertyName tokenName}}: {{{cssValue tokenValue}}} !default; 36 | {{/each}} 37 | 38 | 39 | {{/if}} 40 | {{/each}} 41 | 42 | {{#each colorRamps}} 43 | {{this}} 44 | ${{sanitizeCssPropertyName name}}: {{{cssValue value}}} 45 | {{/each}} 46 | -------------------------------------------------------------------------------- /src/templates/yaml.hbs: -------------------------------------------------------------------------------- 1 | {{comment fileHeader '# '}} 2 | 3 | {{#each groups}} 4 | {{#if groupId }} 5 | 6 | # 7 | # {{ groupId }} 8 | # 9 | {{/if}} 10 | {{comment groupInfo.comment '# '}} 11 | {{#each tokens}} 12 | {{#if tokenDefinition.comment}} 13 | {{comment tokenDefinition.comment '# '}} 14 | {{/if}} 15 | {{#each themes }} 16 | {{tokenName}}: "{{{cssValue tokenValue}}}" 17 | {{/each}} 18 | {{/each}} 19 | {{/each}} 20 | -------------------------------------------------------------------------------- /src/terminal.ts: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const ciInfo = require('ci-info'); 3 | 4 | // 5 | // Terminal colors for various kind of messages 6 | // 7 | const tcOrange = '#ffcc00'; 8 | const tcRed = '#fa2040'; 9 | const tcBlue = '#6ab3ff'; 10 | const tcPurple = '#d1d7ff'; 11 | 12 | /** Do not use fancy color output if the output stream is not a terminal 13 | * (e.g. if we're redirecting errors to a log file) or when in a CI environment. 14 | * Note that the debug console in VSCode returns 'undefined' for isTTY. 15 | */ 16 | let gUseColor = (process.stdout.isTTY ?? false) && !ciInfo.isCI; 17 | 18 | export const terminal = { 19 | useColor: (flag: boolean): void => { 20 | gUseColor = flag; 21 | }, 22 | autoFormat: (m: string): string => { 23 | return m 24 | .replace(/("(.*)")/g, (x) => { 25 | return terminal.string(x.slice(1, -1)); 26 | }) 27 | .replace(/(`(.*)`)/g, (x) => { 28 | return terminal.keyword(x); 29 | }); 30 | }, 31 | success: (m = ''): string => { 32 | chalk.green('✔︎ ' + m); 33 | return gUseColor ? chalk.bold.green('✔︎ ' + m) : '✔︎ ' + m; 34 | }, 35 | error: (m = ''): string => { 36 | return gUseColor ? chalk.hex(tcRed)(chalk.bold('✘ ' + m)) : '✘ ' + m; 37 | }, 38 | warning: (m = ''): string => { 39 | return gUseColor 40 | ? chalk.hex(tcOrange)(chalk.bold('⚠️ ' + m)) 41 | : '⚠ ' + m; 42 | }, 43 | path: (m = ''): string => { 44 | return gUseColor ? chalk.hex(tcBlue).italic(m) : m; 45 | }, 46 | keyword: (m = ''): string => { 47 | return gUseColor ? chalk.hex(tcOrange)(m) : m; 48 | }, 49 | string: (m = ''): string => { 50 | return gUseColor 51 | ? chalk.hex(tcOrange)('"' + chalk.italic(m) + '"') 52 | : '"' + m + '"'; 53 | }, 54 | dim: (m = ''): string => { 55 | return gUseColor ? chalk.hex('#999')(m) : m; 56 | }, 57 | time: (t = new Date()): string => { 58 | return gUseColor 59 | ? chalk.hex(tcPurple)(`[${t.toLocaleTimeString()}]`) 60 | : '[' + t + ']'; 61 | }, 62 | link: (m: string): string => { 63 | return gUseColor 64 | ? '\n▷ ' + 65 | chalk.hex(tcPurple)( 66 | 'https://github.com/arnog/chromatic/docs/errors/' + m + '.md' 67 | ) 68 | : '\n▷ https://github.com/arnog/chromatic/docs/errors/' + m + '.md'; 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const stringSimilarity = require('string-similarity'); 2 | 3 | export function findClosestKey( 4 | key: string, 5 | o: Record | Map | string[] 6 | ): string { 7 | if (!key || !o) return ''; 8 | let keys: string[]; 9 | if (o instanceof Map) { 10 | keys = Array.from(o.keys()); 11 | } else if (Array.isArray(o)) { 12 | keys = o; 13 | } else { 14 | keys = Object.keys(o); 15 | } 16 | if (keys.length === 0) return ''; 17 | const result = stringSimilarity.findBestMatch(key, keys); 18 | return result.bestMatch.rating > 0.1 ? result.bestMatch.target : ''; 19 | } 20 | 21 | export function getSuggestion( 22 | key: string, 23 | o: Record | Map | string[] 24 | ): string { 25 | const alt = findClosestKey(key, o); 26 | return alt ? `. Did you mean "${alt}"?` : ''; 27 | } 28 | -------------------------------------------------------------------------------- /src/value-parser.ts: -------------------------------------------------------------------------------- 1 | import { throwErrorWithContext, ErrorCode, SyntaxError } from './errors'; 2 | import { terminal } from './terminal'; 3 | import { getSuggestion } from './utils'; 4 | import { 5 | Value, 6 | Frequency, 7 | FrequencyUnit, 8 | Time, 9 | TimeUnit, 10 | Length, 11 | LengthUnit, 12 | BaseLengthUnits, 13 | MultiLength, 14 | Angle, 15 | AngleUnit, 16 | StringValue, 17 | Percentage, 18 | NumberValue, 19 | ArrayValue, 20 | compareValue, 21 | asColor, 22 | asInteger, 23 | asPercent, 24 | asDegree, 25 | isArray, 26 | isAngle, 27 | isColor, 28 | isLength, 29 | isNumber, 30 | isFrequency, 31 | isPercentage, 32 | isTime, 33 | isString, 34 | isZero, 35 | makeValueFrom, 36 | assertLength, 37 | scaleLength, 38 | promoteToMulti, 39 | } from './value'; 40 | import { 41 | COLOR_ARGUMENTS_FUNCTIONS, 42 | COLOR_FUNCTION_ARGUMENTS, 43 | COLOR_FUNCTIONS, 44 | scaleColor, 45 | } from './color-functions'; 46 | 47 | // @todo: convert frequency and time (1/s -> Hz) 48 | // @todo: have a base-font-size property in the tokenfile as well (under global: ) 49 | 50 | export interface ValueParserOptions { 51 | baseUnits?: BaseLengthUnits; 52 | 53 | /** When an alias (identifier in {}) is encountered, this function 54 | * is called to resolve it. 55 | * Return either the resolved value, or a string which is a suggestion 56 | * for the best matching identifier. 57 | */ 58 | aliasResolver?: (identifier: string) => Value | string; 59 | } 60 | 61 | // Definition of the functions that can be used in the expression 62 | // of token values. 63 | let FUNCTIONS: { 64 | [key: string]: (...args: Value[]) => Value; 65 | } = {}; 66 | FUNCTIONS = { 67 | /** The calc() function is a no-op, but it's there for compatibility with CSS */ 68 | calc: (x: Value): Value => x, 69 | min: (a: Value, b: Value): Value => { 70 | return compareValue(a, b) < 0 ? a : b; 71 | }, 72 | max: (a: Value, b: Value): Value => { 73 | return compareValue(a, b) < 0 ? b : a; 74 | }, 75 | clamp(a: Value, b: Value, c: Value): Value { 76 | return compareValue(b, a) < 0 ? a : compareValue(b, c) > 0 ? c : b; 77 | }, 78 | 79 | scale: (arg1: Value, arg2: Value, arg3: Value, arg4: Value): ArrayValue => { 80 | if (isColor(arg1)) { 81 | return scaleColor(arg1, arg2, arg3, arg4); 82 | } else if (isLength(arg1)) { 83 | return scaleLength(arg1, arg2); 84 | } 85 | }, 86 | ...COLOR_FUNCTIONS, 87 | }; 88 | 89 | const FUNCTION_ARGUMENTS = { 90 | calc: 'any', 91 | min: 'any, any', 92 | max: 'any, any', 93 | clamp: 'any, any, any', 94 | ...COLOR_FUNCTION_ARGUMENTS, 95 | }; 96 | 97 | function validateArguments(fn: string, args: any[]): void { 98 | const expectedArguments = FUNCTION_ARGUMENTS[fn] 99 | ?.split(',') 100 | .map((x) => x.trim()); 101 | if (expectedArguments) { 102 | expectedArguments.forEach((x: string, i: number) => { 103 | const types = x.split('|').map((x) => x.trim()); 104 | 105 | if (!types.includes('none') && !args[i]) { 106 | throw new SyntaxError( 107 | ErrorCode.MissingArgument, 108 | String(i + 1), 109 | fn, 110 | types.join(', ') 111 | ); 112 | } 113 | 114 | if ( 115 | args[i] && 116 | !types.includes('any') && 117 | !types.includes(args[i]?.type()) 118 | ) { 119 | throw new SyntaxError( 120 | ErrorCode.ExpectedArgument, 121 | String(i + 1), 122 | fn, 123 | types.join(', ') 124 | ); 125 | } 126 | }); 127 | if (args.length > expectedArguments.length) { 128 | throw new SyntaxError( 129 | ErrorCode.TooManyArguments, 130 | fn, 131 | expectedArguments.join(', ') 132 | ); 133 | } 134 | } 135 | } 136 | 137 | class Stream { 138 | /** 139 | * @param {string} s - A token value expression 140 | * @param {object} options 141 | * @param {number} "base-font-size" - The number of pixels of 1rem. 142 | */ 143 | s = ''; 144 | index = 0; 145 | options: ValueParserOptions = {}; 146 | constructor(s: string, options: ValueParserOptions = {}) { 147 | this.s = s; 148 | this.index = 0; 149 | this.options = options; 150 | } 151 | isEOF(): boolean { 152 | return this.index >= this.s.length; 153 | } 154 | lookAhead(n: number): string { 155 | return this.s.slice(this.index, this.index + n); 156 | } 157 | 158 | skipWhiteSpace(): void { 159 | this.match(/^\s*/); 160 | } 161 | match(target: string): boolean; 162 | match(target: RegExp): string; 163 | match(target: string | RegExp): string | boolean { 164 | if (typeof target === 'string') { 165 | if (this.lookAhead(target.length) === target) { 166 | this.index += target.length; 167 | return target; 168 | } 169 | } else { 170 | const m = this.s.slice(this.index).match(target); 171 | if (m && m[0]) { 172 | this.index += m[0].length; 173 | return m[1] || true; 174 | } 175 | } 176 | return undefined; 177 | } 178 | 179 | error(code: ErrorCode, ...args: string[]): void { 180 | const prefix = this.s.slice(0, this.index).match(/^(.*)/)?.[1] ?? ''; 181 | const suffix = this.s.slice(this.index).match(/(.*)$/)?.[1] ?? ''; 182 | throwErrorWithContext( 183 | [prefix + terminal.dim(suffix), ' '.repeat(prefix.length) + '⇧'], 184 | code, 185 | ...args 186 | ); 187 | } 188 | 189 | /** Apply an arithmetic operation when at least one of the operands is a Length 190 | * 191 | */ 192 | applyOpToLength( 193 | op: string, 194 | lhs: Length | NumberValue, 195 | rhs: Length | NumberValue 196 | ): Value { 197 | if (isNumber(lhs) && op === '/') this.error(ErrorCode.InvalidOperand); 198 | if (!isNumber(lhs) && !isNumber(rhs) && op === '*') 199 | this.error(ErrorCode.InvalidOperand); 200 | 201 | const opFn = { 202 | '+': (a: any, b: any): any => a + b, 203 | '-': (a: any, b: any): any => a - b, 204 | '*': (a: any, b: any): any => a * b, 205 | '/': (a: any, b: any): any => a / b, 206 | }[op]; 207 | 208 | if (isNumber(lhs)) { 209 | assertLength(rhs); 210 | if (rhs.unit === 'multi') { 211 | const multiLength = {}; 212 | Object.keys(rhs.value as MultiLength).forEach((unit) => { 213 | multiLength[unit] = opFn(lhs.value, rhs.value[unit]); 214 | }); 215 | return new Length(multiLength); 216 | } 217 | return new Length(opFn(lhs.value, rhs.value), rhs.unit); 218 | } 219 | if (isNumber(rhs)) { 220 | if (typeof lhs.value === 'number') { 221 | return new Length(opFn(lhs.value, rhs.value), lhs.unit); 222 | } 223 | const multiLength = {}; 224 | Object.keys(lhs.value as MultiLength).forEach((unit) => { 225 | multiLength[unit] = opFn(lhs.value[unit], rhs.value); 226 | }); 227 | return new Length(multiLength); 228 | } 229 | // We've dealt with the case where one of the two operand is a number. 230 | // Now, both operands are Length 231 | if (op === '/') { 232 | if (lhs.unit === 'multi' || rhs.unit === 'multi') { 233 | this.error(ErrorCode.InvalidOperand); 234 | } 235 | 236 | if (lhs.unit === rhs.unit) { 237 | // If the units are the same, we can calculate the result 238 | // even if the units are relative (em, vh, etc...) 239 | return new NumberValue((lhs.value as number) / (rhs.value as number)); 240 | } else { 241 | // The units are not the same. Attempt to conver them to a scalar 242 | return new NumberValue(lhs.canonicalScalar() / rhs.canonicalScalar()); 243 | } 244 | } 245 | // Normalize them both to multi-units 246 | const lhsMulti = promoteToMulti(lhs); 247 | const rhsMulti = promoteToMulti(rhs); 248 | 249 | // Apply the operation on the union of both operands 250 | const multiLength = {}; 251 | [ 252 | ...Object.keys(lhsMulti.value as MultiLength), 253 | ...Object.keys(rhsMulti.value as MultiLength), 254 | ].forEach((unit) => { 255 | if (typeof rhsMulti.value[unit] === 'undefined') { 256 | multiLength[unit] = lhsMulti.value[unit]; 257 | } else if (typeof lhsMulti.value[unit] === 'undefined') { 258 | multiLength[unit] = rhsMulti.value[unit]; 259 | } else { 260 | multiLength[unit] = opFn(lhsMulti.value[unit], rhsMulti.value[unit]); 261 | } 262 | }); 263 | return new Length(multiLength); 264 | } 265 | 266 | parseUnit(num: number): Value { 267 | // Check if a number (or group) is followed (immediately) by a unit 268 | if (this.match('%')) { 269 | return new Percentage(num); 270 | } 271 | let unit = this.match( 272 | /^(em|ex|ch|rem|vw|vh|vmin|vmax|px|cm|mm|in|pt|pc|Q)/ 273 | ); 274 | if (unit) { 275 | return new Length(num, unit as LengthUnit); 276 | } 277 | unit = this.match(/^(deg|°|rad|grad|turn)/); 278 | if (unit) { 279 | return new Angle(num, (unit === '°' ? 'deg' : unit) as AngleUnit); 280 | } 281 | unit = this.match(/^(ms|s)/); 282 | if (unit) { 283 | return new Time(num, unit as TimeUnit); 284 | } 285 | unit = this.match(/^(khz|hz|kHz|Hz)/); 286 | if (unit) { 287 | return new Frequency(num, unit.toLowerCase() as FrequencyUnit); 288 | } 289 | unit = this.match(/^([a-zA-Z]+)/); 290 | if (unit) { 291 | this.error(ErrorCode.UnknownUnit, unit); 292 | } 293 | return new NumberValue(num); 294 | } 295 | 296 | parseIndex(v: Value): Value { 297 | let result = v; 298 | if (this.match('[')) { 299 | if (v.type() !== 'array') { 300 | this.error(ErrorCode.UnexpectedOpenBracket); 301 | } else { 302 | const index = asInteger(this.parseExpression(), NaN); 303 | if (isNaN(index)) this.error(ErrorCode.ExpectedIntegerIndex); 304 | result = (v as ArrayValue).get(index); 305 | this.skipWhiteSpace(); 306 | if (!this.match(']')) { 307 | this.error(ErrorCode.ExpectedCloseBracket); 308 | } 309 | } 310 | } 311 | return result; 312 | } 313 | 314 | parseLiteral(): Value { 315 | let result: Value; 316 | const saveIndex = this.index; 317 | const op = this.match(/^\s*([+\-])\s*/); 318 | if (op) { 319 | const operand = this.parseLiteral(); 320 | if (op === '-') { 321 | // Unary operator 322 | if (isPercentage(operand)) { 323 | return new Percentage(-100 * asPercent(operand)); 324 | } 325 | if (isNumber(operand)) { 326 | return new NumberValue(-operand.value); 327 | } 328 | if (isAngle(operand)) { 329 | return new Angle(-operand.value, operand.unit); 330 | } 331 | if (isLength(operand)) { 332 | return this.applyOpToLength('-', new Length(0, 'px'), operand); 333 | } 334 | this.error(ErrorCode.InvalidUnaryOperand); 335 | } 336 | return operand; 337 | } 338 | 339 | const num = this.match(/^([0-9]*\.[0-9]+|\.?[0-9]+)/); 340 | if (num) { 341 | result = this.parseUnit(parseFloat(num)); 342 | } 343 | 344 | if (!result && this.match('[')) { 345 | // It's an array literal 346 | const array = []; 347 | while (this.lookAhead(1) !== ']' && !this.isEOF()) { 348 | const element = this.parseExpression(); 349 | if (!element) { 350 | this.error(ErrorCode.SyntaxError); 351 | } 352 | array.push(element); 353 | this.match(/^(\s*,?|\s+)/); 354 | } 355 | 356 | if (this.isEOF()) { 357 | this.error(ErrorCode.ExpectedCloseBracket); 358 | } 359 | this.match(']'); 360 | return new ArrayValue(array); 361 | } 362 | 363 | if (!result && this.match('"')) { 364 | // It's a string 365 | let s = ''; 366 | while (this.lookAhead(1) !== '"' && !this.isEOF()) { 367 | if (this.lookAhead(1) === '\\') { 368 | // Escape character 369 | s += this.s[this.index + 1]; 370 | this.index += 2; 371 | } else { 372 | s += this.s[this.index]; 373 | this.index += 1; 374 | } 375 | } 376 | 377 | if (this.isEOF()) { 378 | this.error(ErrorCode.ExpectedQuote); 379 | } 380 | this.match('"'); 381 | return new StringValue(s); 382 | } 383 | 384 | if (!result && this.match('{')) { 385 | // It's an alias 386 | const identifier = this.match(/^([a-zA-Z_-][a-zA-Z0-9\._-]*)/); 387 | if (identifier) { 388 | let alias = this.options?.aliasResolver(identifier); 389 | if (typeof alias === 'string') { 390 | // If that didn't work, try an implicit color scale... 391 | // e.g. "red-200" 392 | const m = identifier.match(/^(.+)-([0-9]{2,3})$/); 393 | if (m) { 394 | const resolvedValue = this.options?.aliasResolver(m[1]); 395 | if (typeof resolvedValue !== 'string') { 396 | if (isArray(resolvedValue)) { 397 | const index = Math.round(parseInt(m[2]) / 100); 398 | alias = resolvedValue.get(index); 399 | } else if (isColor(resolvedValue)) { 400 | const index = Math.round(parseInt(m[2]) / 100); 401 | 402 | alias = scaleColor(resolvedValue)?.get(index); 403 | } else if (isLength(resolvedValue)) { 404 | const index = 405 | m[2] === '50' ? 0 : Math.round(parseInt(m[2]) / 100); 406 | alias = scaleLength(resolvedValue)?.get(index); 407 | } 408 | } else if (typeof resolvedValue === 'string') { 409 | // A string indicate the identifier could not be 410 | // resolved. The string is the suggestion 411 | this.error(ErrorCode.UnknownToken, m[1], resolvedValue); 412 | } else this.error(ErrorCode.InvalidOperand); 413 | } 414 | } 415 | if (typeof alias === 'string') { 416 | this.error(ErrorCode.UnknownToken, identifier, alias); 417 | } 418 | result = alias as Value; 419 | if (result) { 420 | // Clone the result of the alias, since we'll need to change 421 | // the source 422 | result = makeValueFrom(result); 423 | result.setSource('{' + identifier + '}'); 424 | } 425 | } 426 | if (!this.match('}')) { 427 | this.error(ErrorCode.ExpectedCloseCurlyBracket); 428 | } 429 | } 430 | 431 | if (!result) { 432 | // Attempt to parse a color as a hex value 433 | result = asColor(this.match(/^\s*(#[0-9a-fA-F]{3,8})/)); 434 | } 435 | if (!result) { 436 | // Backtrack and attempt to parse as a color name 437 | this.index = saveIndex; 438 | result = asColor(this.match(/^\s*([a-zA-Z]+)/)); 439 | } 440 | if (!result) { 441 | // Backtrack 442 | this.index = saveIndex; 443 | } 444 | return result; 445 | } 446 | 447 | /* Argument to color functions (rgb, hsl, etc...) have a bit of 448 | a peculiar syntax. The arguments can be either comma or space delimited, 449 | and the last one (the opacity) can be space, comma or "/". 450 | And it's optional */ 451 | parseColorArguments(): (Value | Value)[] { 452 | const result: (Value | Value)[] = []; 453 | 454 | this.skipWhiteSpace(); 455 | if (!this.match('(')) return undefined; 456 | 457 | let arg = this.parseExpression(); 458 | if (arg) { 459 | result.push(arg); 460 | 461 | if (!this.match(/^(\s*,?|\s+)/)) { 462 | this.match(')'); 463 | return result; 464 | } 465 | 466 | arg = this.parseExpression(); 467 | if (arg) { 468 | result.push(arg); 469 | 470 | if (!this.match(/^(\s*,?|\s+)/)) { 471 | this.match(')'); 472 | return result; 473 | } 474 | 475 | arg = this.parseExpression(); 476 | if (arg) { 477 | result.push(arg); 478 | 479 | // Last argument (opacity) can be separated with a "slash" 480 | if (!this.match(/^(\s*,?|\s+|\s*\/)/)) { 481 | this.match(')'); 482 | return result; 483 | } 484 | 485 | arg = this.parseExpression(); 486 | if (arg) { 487 | result.push(arg); 488 | } 489 | } 490 | } 491 | } 492 | 493 | this.match(')'); 494 | 495 | return result; 496 | } 497 | 498 | parseArguments(): Value[] { 499 | this.skipWhiteSpace(); 500 | if (!this.match('(')) return undefined; 501 | 502 | const result = []; 503 | while (this.lookAhead(1) !== ')' && !this.isEOF()) { 504 | const argument = this.parseExpression(); 505 | if (!argument) { 506 | this.error(ErrorCode.SyntaxError); 507 | } 508 | result.push(argument); 509 | this.match(/^(\s*,?|\s+)/); 510 | } 511 | if (this.isEOF()) { 512 | this.error(ErrorCode.ExpectedCloseParen); 513 | } 514 | this.match(')'); 515 | 516 | return result; 517 | } 518 | 519 | parseCall(): Value { 520 | const saveIndex = this.index; 521 | const fn = this.match(/^([a-zA-Z\-]+)/); 522 | if (fn) { 523 | if (!FUNCTIONS[fn]) { 524 | if (this.lookAhead(1) === '(') { 525 | this.error( 526 | ErrorCode.UnknownFunction, 527 | fn, 528 | getSuggestion(fn, FUNCTIONS) 529 | ); 530 | } 531 | } else { 532 | const args = COLOR_ARGUMENTS_FUNCTIONS.includes(fn) 533 | ? this.parseColorArguments() 534 | : this.parseArguments(); 535 | if (args) { 536 | try { 537 | validateArguments(fn, args); 538 | } catch (err) { 539 | if (err.code) { 540 | this.error(err.code, ...err.args); 541 | } else { 542 | this.error(err.message); 543 | } 544 | } 545 | return FUNCTIONS[fn](...args); 546 | } else { 547 | this.error(ErrorCode.SyntaxError); 548 | } 549 | } 550 | } 551 | // Backtrack 552 | this.index = saveIndex; 553 | return undefined; 554 | } 555 | 556 | parseTerminal(): Value { 557 | const result = this.parseCall() || this.parseGroup() || this.parseLiteral(); 558 | 559 | if (!result) return result; 560 | 561 | return this.parseIndex(result); 562 | } 563 | 564 | parseFactor(): Value { 565 | let lhs = this.parseTerminal(); 566 | 567 | let op = this.match(/^\s*([*|/])\s*/); 568 | while (op) { 569 | const opFn = { 570 | '*': (a: any, b: any): any => a * b, 571 | '/': (a: any, b: any): any => a / b, 572 | }[op]; 573 | // Multiplication or division 574 | const rhs = this.parseTerminal(); 575 | 576 | if (!rhs) this.error(ErrorCode.ExpectedOperand); 577 | // Type combination rules (for * AND /) 578 | // --- 579 | // num * num -> num 580 | // num * angle -> angle 581 | // num * percent -> percent 582 | // num * length -> length 583 | // Other combinations are invalid, but division of two 584 | // values of the same type is valid (and yields a unitless number) 585 | if (isNumber(rhs)) { 586 | if (isNumber(lhs)) { 587 | lhs = new NumberValue(opFn(lhs.value, rhs.value)); 588 | } else if (isPercentage(lhs)) { 589 | lhs = new Percentage(opFn(lhs.value, rhs.value)); 590 | } else if (isLength(lhs)) { 591 | lhs = this.applyOpToLength(op, lhs, rhs); 592 | } else if (isAngle(lhs)) { 593 | lhs = new Angle(opFn(lhs.value, rhs.value), lhs.unit); 594 | } else if (isFrequency(lhs)) { 595 | lhs = new Frequency(opFn(lhs.value, rhs.value), lhs.unit); 596 | } else if (isTime(lhs)) { 597 | lhs = new Time(opFn(lhs.value, rhs.value), lhs.unit); 598 | } 599 | } else if ((isNumber(lhs) || isLength(lhs)) && isLength(rhs)) { 600 | return this.applyOpToLength(op, lhs, rhs); 601 | } else if (isNumber(lhs)) { 602 | if (isPercentage(rhs)) { 603 | lhs = new Percentage(opFn(lhs.value, rhs.value)); 604 | } else if (isLength(rhs)) { 605 | lhs = this.applyOpToLength(op, lhs, rhs); 606 | } else if (isAngle(rhs)) { 607 | lhs = new Angle(opFn(lhs.value, rhs.value), rhs.unit); 608 | } else if (isFrequency(rhs)) { 609 | lhs = new Frequency(opFn(lhs.value, rhs.value), rhs.unit); 610 | } else if (isTime(rhs)) { 611 | lhs = new Time(opFn(lhs.value, rhs.value), rhs.unit); 612 | } 613 | } else if (op === '/' && lhs.type() === rhs.type()) { 614 | lhs = new NumberValue(lhs.canonicalScalar() / rhs.canonicalScalar()); 615 | } else { 616 | this.error(ErrorCode.InvalidOperand); 617 | } 618 | op = this.match(/^\s*([*|/])\s*/); 619 | } 620 | 621 | return lhs; 622 | } 623 | 624 | parseTerm(): Value { 625 | let lhs = this.parseFactor(); 626 | 627 | let op = this.match(/^\s*([+\-])\s*/); 628 | 629 | while (op) { 630 | const opFn = { 631 | '+': (a: any, b: any): any => a + b, 632 | '-': (a: any, b: any): any => a - b, 633 | }[op]; 634 | // Type combination rules (for + AND -) 635 | // --- 636 | // string + any -> string 637 | // any + string -> string 638 | // num + num -> num 639 | // percentage + num -> percent 640 | // num + percentage -> percent 641 | // percentage + percentage -> percent 642 | // angle + angle -> angle 643 | // length + length -> length 644 | // Other combinations are invalid. 645 | const rhs = this.parseFactor(); 646 | 647 | if (!rhs) this.error(ErrorCode.ExpectedOperand); 648 | 649 | if (isString(lhs) || isString(rhs)) { 650 | if (op === '-') this.error(ErrorCode.InvalidOperand); 651 | lhs = new StringValue(opFn(lhs.css(), rhs.css())); 652 | } else if (isNumber(lhs) && isNumber(rhs)) { 653 | lhs = new NumberValue(opFn(lhs.value, rhs.value)); 654 | } else if ( 655 | (isZero(lhs) || isPercentage(lhs)) && 656 | (isZero(rhs) || isPercentage(rhs)) 657 | ) { 658 | lhs = new Percentage(100 * opFn(asPercent(lhs), asPercent(rhs))); 659 | } else if (isZero(lhs) && isTime(rhs)) { 660 | lhs = new Time(opFn(0, rhs.value), rhs.unit); 661 | } else if (isTime(lhs) && isZero(rhs)) { 662 | lhs = new Time(lhs.value, lhs.unit); 663 | } else if (isTime(lhs) && isTime(rhs)) { 664 | if (lhs.unit === rhs.unit) { 665 | lhs = new Time(opFn(lhs.value, rhs.value), lhs.unit); 666 | } else { 667 | lhs = new Time( 668 | opFn(lhs.canonicalScalar(), rhs.canonicalScalar()), 669 | 's' 670 | ); 671 | } 672 | } else if (isZero(lhs) && isFrequency(rhs)) { 673 | lhs = new Frequency(opFn(0, rhs.value), rhs.unit); 674 | } else if (isFrequency(lhs) && isZero(rhs)) { 675 | lhs = new Frequency(lhs.value, lhs.unit); 676 | } else if (isFrequency(lhs) && isFrequency(rhs)) { 677 | if (lhs.unit === rhs.unit) { 678 | lhs = new Frequency(opFn(lhs.value, rhs.value), lhs.unit); 679 | } else { 680 | lhs = new Frequency( 681 | opFn(lhs.canonicalScalar(), rhs.canonicalScalar()), 682 | 'hz' 683 | ); 684 | } 685 | } else if (isZero(lhs) && isAngle(rhs)) { 686 | lhs = new Angle(opFn(0, rhs.value), rhs.unit); 687 | } else if (isAngle(lhs) && isZero(rhs)) { 688 | lhs = new Angle(lhs.value, lhs.unit); 689 | } else if (isAngle(lhs) && isAngle(rhs)) { 690 | if (lhs.unit === rhs.unit) { 691 | lhs = new Angle(opFn(lhs.value, rhs.value), lhs.unit); 692 | } else { 693 | lhs = new Angle(opFn(asDegree(lhs), asDegree(rhs)), 'deg'); 694 | } 695 | } else if ( 696 | (isZero(lhs) || isLength(lhs)) && 697 | (isZero(rhs) || isLength(rhs)) 698 | ) { 699 | lhs = this.applyOpToLength(op, lhs, rhs); 700 | } else { 701 | this.error(ErrorCode.InvalidOperand); 702 | } 703 | op = this.match(/^\s*([+\-])\s*/); 704 | } 705 | 706 | return lhs; 707 | } 708 | 709 | parseGroup(): Value { 710 | let result: Value; 711 | if (this.match('(')) { 712 | result = this.parseExpression(); 713 | this.skipWhiteSpace(); 714 | if (!this.match(')')) { 715 | this.error(ErrorCode.ExpectedCloseParen); 716 | } 717 | } 718 | 719 | if (result && isNumber(result)) { 720 | // If the value of the group is a number 721 | // check and handle units that might be after it. 722 | // "(12 + 5)px" 723 | result = this.parseUnit(result.value); 724 | } 725 | 726 | return result; 727 | } 728 | 729 | parseExpression(): Value { 730 | this.skipWhiteSpace(); 731 | return this.parseTerm(); 732 | } 733 | } 734 | 735 | export function parseValue( 736 | expression: string, 737 | options: ValueParserOptions = {} 738 | ): Value { 739 | const stream = new Stream(expression, options); 740 | const result = stream.parseExpression(); 741 | stream.skipWhiteSpace(); 742 | if (!stream.isEOF()) { 743 | // There was some additional content that we couldn't parse. 744 | // Return 'undefined' to avoid partially parsing things 745 | // that shouldn't be. For example "3px red" should 746 | // be interpreted as a string, not as "3px". 747 | return undefined; 748 | } 749 | if (result) { 750 | result.setSource(expression); 751 | } 752 | return result; 753 | } 754 | -------------------------------------------------------------------------------- /src/value.ts: -------------------------------------------------------------------------------- 1 | const colorName = require('color-name'); 2 | 3 | import { throwError, ErrorCode, SyntaxError } from './errors'; 4 | 5 | export type ValueType = 6 | | 'string' 7 | | 'number' 8 | | 'percentage' 9 | | 'angle' 10 | | 'color' 11 | | 'length' 12 | | 'time' 13 | | 'frequency' 14 | | 'array'; 15 | 16 | /** 17 | * Those are relative units that can't be evaluated statically, as they 18 | * depend on the rendering environment (the size of the base font of the 19 | * document, the metrics of the current font, the dimension of the view port. 20 | * However, it is possible to provide values for those to valueParser, 21 | * in which case they will get evaluated. 22 | */ 23 | 24 | export interface BaseLengthUnits { 25 | rem?: number; 26 | em?: number; 27 | ex?: number; 28 | ch?: number; 29 | vh?: number; 30 | vw?: number; 31 | } 32 | 33 | export function clampByte(v: number): number { 34 | if (v < 0) return 0; 35 | if (v > 255) return 255; 36 | return Math.round(v); 37 | } 38 | 39 | export class Value { 40 | private source = ''; 41 | css(): string { 42 | return ''; 43 | } 44 | type(): ValueType { 45 | return undefined; 46 | } 47 | canonicalScalar(): number { 48 | return 0; 49 | } 50 | getSource(): string { 51 | return this.source; 52 | } 53 | setSource(source: string): void { 54 | this.source = source; 55 | } 56 | equals(v: Value): boolean { 57 | return ( 58 | this.type() === v.type() && this.canonicalScalar() == v.canonicalScalar() 59 | ); 60 | } 61 | [key: string]: unknown; 62 | } 63 | 64 | export type LengthUnit = 65 | | 'px' 66 | | 'cm' 67 | | 'mm' 68 | | 'Q' 69 | | 'in' 70 | | 'pc' 71 | | 'pt' 72 | | 'rem' 73 | | 'em' 74 | | 'ex' 75 | | 'ch' 76 | | 'vw' 77 | | 'vh' 78 | | 'vmin' 79 | | 'vmax' 80 | | 'multi'; 81 | 82 | export type AngleUnit = 'deg' | 'grad' | 'rad' | 'degree' | 'turn'; 83 | 84 | export type TimeUnit = 's' | 'ms'; 85 | 86 | export type FrequencyUnit = 'hz' | 'khz'; 87 | 88 | export function roundTo(num: number, precision: number): number { 89 | return ( 90 | Math.round(num * Math.pow(10, precision) + 1e-14) / Math.pow(10, precision) 91 | ); 92 | } 93 | 94 | export class Percentage extends Value { 95 | value: number; /* [0..100] */ 96 | constructor(from: number) { 97 | super(); 98 | this.value = from; 99 | } 100 | css(): string { 101 | return roundTo(this.value, 2) + '%'; 102 | } 103 | type(): ValueType { 104 | return 'percentage'; 105 | } 106 | canonicalScalar(): number { 107 | return this.value / 100; 108 | } 109 | equals(v: Value): boolean { 110 | if (isLength(v)) { 111 | const v1 = promoteToMulti(this); 112 | const v2 = promoteToMulti(v); 113 | return [...Object.keys(v1.value), ...Object.keys(v2.value)].every( 114 | (x) => v1.value[x] === v2.value[x] 115 | ); 116 | } 117 | 118 | return false; 119 | } 120 | } 121 | 122 | export class Angle extends Value { 123 | value: number; 124 | unit: AngleUnit; 125 | constructor(from: number, unit: AngleUnit) { 126 | super(); 127 | this.value = from; 128 | this.unit = unit; 129 | } 130 | css(): string { 131 | return roundTo(this.value, 2) + this.unit; 132 | } 133 | type(): ValueType { 134 | return 'angle'; 135 | } 136 | canonicalScalar(): number { 137 | return asDegree(this); 138 | } 139 | } 140 | 141 | export interface MultiLength { 142 | // Absolute canonical length 143 | px?: number; 144 | 145 | // Relative lengths 146 | em?: number; 147 | ex?: number; 148 | ch?: number; 149 | rem?: number; 150 | vw?: number; 151 | vh?: number; 152 | vmin?: number; 153 | vmax?: number; 154 | } 155 | 156 | export class Length extends Value { 157 | value: number | MultiLength; 158 | unit: LengthUnit; 159 | constructor(from: number | MultiLength, unit?: LengthUnit) { 160 | super(); 161 | if (typeof from === 'number') { 162 | this.value = from; 163 | if (from === 0) { 164 | this.unit = 'px'; 165 | } else { 166 | this.unit = unit; 167 | } 168 | } else if (typeof unit === 'undefined') { 169 | const nonZeroKeys: LengthUnit[] = Object.keys(from).filter( 170 | (x) => typeof from[x] === 'number' && from[x] !== 0 171 | ) as LengthUnit[]; 172 | if (nonZeroKeys.length === 0) { 173 | // Everything's zero, return the canonical zero length: 0px 174 | this.value = 0; 175 | this.unit = 'px'; 176 | } else if (nonZeroKeys.length === 1) { 177 | // A single non-zero unit? Return that unit. 178 | this.value = from[nonZeroKeys[0]]; 179 | this.unit = nonZeroKeys[0]; 180 | } else { 181 | this.value = from; 182 | this.unit = 'multi'; 183 | } 184 | } else { 185 | // Force promotion to multi 186 | this.value = from; 187 | this.unit = 'multi'; 188 | console.assert(unit === 'multi'); 189 | } 190 | } 191 | css(): string { 192 | if (typeof this.value === 'number') { 193 | // If it's a number, display "0" and "NaN" without units 194 | return this.value === 0 || isNaN(this.value) 195 | ? Number(this.value).toString() 196 | : roundTo(this.value, 2) + this.unit; 197 | } 198 | 199 | // It's a multi-unit length. 200 | 201 | const result: MultiLength = {}; 202 | let units = Object.keys(this.value); 203 | 204 | if (units.length > 1) { 205 | // It's a multi-unit length, with multiple units 206 | // Attempt to simplify it 207 | let pxSum = 0; 208 | units.forEach((x) => { 209 | const inPx = asPx(this.value[x], x as LengthUnit); 210 | if (!isNaN(inPx)) { 211 | pxSum += inPx; 212 | } else if (x !== 'px') { 213 | result[x] = this.value[x]; 214 | } 215 | }); 216 | if (pxSum !== 0) { 217 | result['px'] = pxSum; 218 | } 219 | } else { 220 | result[units[0]] = this.value[units[0]]; 221 | } 222 | 223 | units = Object.keys(result); 224 | if (units.length === 1) { 225 | if (units[0] === 'px' && result['px'] === 0) { 226 | return '0'; 227 | } 228 | return roundTo(result[units[0]], 2) + units[0]; 229 | } 230 | 231 | return ( 232 | 'calc(' + 233 | units.map((x) => Number(result[x]).toString() + x).join(' + ') + 234 | ')' 235 | ); 236 | } 237 | type(): ValueType { 238 | return 'length'; 239 | } 240 | canonicalScalar(): number { 241 | return this.unit === 'multi' ? NaN : asPx(this.value as number, this.unit); 242 | } 243 | } 244 | 245 | export class Time extends Value { 246 | value: number; 247 | unit: TimeUnit; 248 | constructor(from: number, unit: TimeUnit) { 249 | super(); 250 | this.value = from; 251 | this.unit = unit; 252 | } 253 | css(): string { 254 | return roundTo(this.value, 2) + this.unit; 255 | } 256 | type(): ValueType { 257 | return 'time'; 258 | } 259 | canonicalScalar(): number { 260 | return this.unit === 'ms' ? this.value / 1000 : this.value; 261 | } 262 | } 263 | 264 | export class Frequency extends Value { 265 | value: number; 266 | unit: FrequencyUnit; 267 | constructor(from: number, unit: FrequencyUnit) { 268 | super(); 269 | this.value = from; 270 | this.unit = unit; 271 | } 272 | css(): string { 273 | return roundTo(this.value, 2) + this.unit; 274 | } 275 | type(): ValueType { 276 | return 'frequency'; 277 | } 278 | canonicalScalar(): number { 279 | return this.unit === 'khz' ? this.value * 1000 : this.value; 280 | } 281 | } 282 | 283 | export class NumberValue extends Value { 284 | value: number; 285 | constructor(from: number) { 286 | super(); 287 | this.value = from; 288 | } 289 | css(): string { 290 | return Number(this.value).toString(); 291 | } 292 | type(): ValueType { 293 | return 'number'; 294 | } 295 | canonicalScalar(): number { 296 | return this.value; 297 | } 298 | } 299 | 300 | export class StringValue extends Value { 301 | value: string; 302 | constructor(from: string) { 303 | super(); 304 | this.value = from; 305 | } 306 | css(quoteLiteral = ''): string { 307 | return quoteLiteral + this.value + quoteLiteral; 308 | } 309 | type(): ValueType { 310 | return 'string'; 311 | } 312 | canonicalScalar(): number { 313 | return parseFloat(this.value); 314 | } 315 | equals(v: Value): boolean { 316 | return isString(v) && this.value === v.value; 317 | } 318 | } 319 | 320 | export class ArrayValue extends Value { 321 | value: Value[]; 322 | constructor(from: Value[]) { 323 | super(); 324 | this.value = from.map(makeValueFrom); 325 | } 326 | get(index: number): Value { 327 | return this.value[index]; 328 | } 329 | type(): ValueType { 330 | return 'array'; 331 | } 332 | css(): string { 333 | return '(' + this.value.map((x) => x.css()).join(', ') + ')'; 334 | } 335 | equals(v: Value): boolean { 336 | return ( 337 | isArray(v) && 338 | this.value.length === v.value.length && 339 | this.value.every((val, idx) => val === v.value[idx]) 340 | ); 341 | } 342 | } 343 | 344 | export function parseColorName(name: string): { 345 | r: number; 346 | g: number; 347 | b: number; 348 | a: number; 349 | } { 350 | const color = colorName[name.toLowerCase()]; 351 | if (color) { 352 | return { 353 | r: color[0], 354 | g: color[1], 355 | b: color[2], 356 | a: 1, 357 | }; 358 | } 359 | 360 | return undefined; 361 | } 362 | 363 | export function parseHex(hex: string): { 364 | r: number; 365 | g: number; 366 | b: number; 367 | a: number; 368 | } { 369 | if (!hex) return undefined; 370 | if (hex[0] !== '#') return undefined; 371 | hex = hex.slice(1); 372 | let result; 373 | if (hex.length <= 4) { 374 | result = { 375 | r: parseInt(hex[0] + hex[0], 16), 376 | g: parseInt(hex[1] + hex[1], 16), 377 | b: parseInt(hex[2] + hex[2], 16), 378 | }; 379 | if (hex.length === 4) { 380 | result.a = parseInt(hex[3] + hex[3], 16) / 255; 381 | } 382 | } else { 383 | result = { 384 | r: parseInt(hex[0] + hex[1], 16), 385 | g: parseInt(hex[2] + hex[3], 16), 386 | b: parseInt(hex[4] + hex[5], 16), 387 | }; 388 | if (hex.length === 8) { 389 | result.a = parseInt(hex[6] + hex[7], 16) / 255; 390 | } 391 | } 392 | if (result && typeof result.a === 'undefined') result.a = 1.0; 393 | return result; 394 | } 395 | 396 | function hueToRgbChannel(t1: number, t2: number, hue: number): number { 397 | if (hue < 0) hue += 6; 398 | if (hue >= 6) hue -= 6; 399 | 400 | if (hue < 1) return (t2 - t1) * hue + t1; 401 | else if (hue < 3) return t2; 402 | else if (hue < 4) return (t2 - t1) * (4 - hue) + t1; 403 | else return t1; 404 | } 405 | 406 | export function hslToRgb( 407 | hue: number, 408 | sat: number, 409 | light: number 410 | ): { r: number; g: number; b: number } { 411 | hue = ((hue + 360) % 360) / 60.0; 412 | light = Math.max(0, Math.min(light, 1.0)); 413 | sat = Math.max(0, Math.min(sat, 1.0)); 414 | const t2 = light <= 0.5 ? light * (sat + 1) : light + sat - light * sat; 415 | const t1 = light * 2 - t2; 416 | return { 417 | r: Math.round(255 * hueToRgbChannel(t1, t2, hue + 2)), 418 | g: Math.round(255 * hueToRgbChannel(t1, t2, hue)), 419 | b: Math.round(255 * hueToRgbChannel(t1, t2, hue - 2)), 420 | }; 421 | } 422 | 423 | export function rgbToHsl( 424 | r: number, 425 | g: number, 426 | b: number 427 | ): { h: number; s: number; l: number } { 428 | r = r / 255; 429 | g = g / 255; 430 | b = b / 255; 431 | const min = Math.min(r, g, b); 432 | const max = Math.max(r, g, b); 433 | 434 | const delta = max - min; 435 | let h: number; 436 | let s: number; 437 | 438 | if (max === min) { 439 | h = 0; 440 | } else if (r === max) { 441 | h = (g - b) / delta; 442 | } else if (g === max) { 443 | h = 2 + (b - r) / delta; 444 | } else if (b === max) { 445 | h = 4 + (r - g) / delta; 446 | } 447 | 448 | h = Math.min(h * 60, 360); 449 | 450 | if (h < 0) { 451 | h += 360; 452 | } 453 | 454 | const l = (min + max) / 2; 455 | 456 | if (max === min) { 457 | s = 0; 458 | } else if (l <= 0.5) { 459 | s = delta / (max + min); 460 | } else { 461 | s = delta / (2 - max - min); 462 | } 463 | 464 | return { h: h, s: s, l: l }; 465 | } 466 | 467 | export interface ColorInterface { 468 | r?: number /* [0..255] */; 469 | g?: number /* [0..255] */; 470 | b?: number /* [0..255] */; 471 | h?: number /* [0..360]deg */; 472 | s?: number; 473 | l?: number; 474 | a?: number /* [0..1] or [0..100]% */; 475 | } 476 | 477 | export class Color extends Value implements ColorInterface { 478 | r?: number; /* [0..255] */ 479 | g?: number; /* [0..255] */ 480 | b?: number; /* [0..255] */ 481 | h?: number; /* [0..360]deg */ 482 | s?: number; 483 | l?: number; 484 | a: number; /* [0..1] or [0..100]% */ 485 | constructor(from: ColorInterface | string) { 486 | super(); 487 | if (typeof from === 'string') { 488 | if (from.toLowerCase() === 'transparent') { 489 | [this.r, this.g, this.b, this.a] = [0, 0, 0, 0]; 490 | [this.h, this.s, this.l] = [0, 0, 0]; 491 | } else { 492 | const rgb = parseHex(from) || parseColorName(from); 493 | if (!rgb) throw new Error(); 494 | Object.assign(this, rgb); 495 | Object.assign(this, rgbToHsl(this.r, this.g, this.b)); 496 | } 497 | } else { 498 | Object.assign(this, from); 499 | // Normalize the RGB/HSL values so that a color value 500 | // always has r, g, b, h, s, l and a. 501 | if (typeof this.r === 'number') { 502 | // RGB data 503 | Object.assign(this, rgbToHsl(this.r, this.g, this.b)); 504 | } else { 505 | // HSL data 506 | console.assert(typeof this.h === 'number'); 507 | this.h = (this.h + 360) % 360; 508 | this.s = Math.max(0, Math.min(1.0, this.s)); 509 | this.l = Math.max(0, Math.min(1.0, this.l)); 510 | Object.assign(this, hslToRgb(this.h, this.s, this.l)); 511 | } 512 | } 513 | if (typeof this.a !== 'number') { 514 | this.a = 1.0; 515 | } 516 | } 517 | type(): ValueType { 518 | return 'color'; 519 | } 520 | opaque(): Color { 521 | return new Color({ r: this.r, g: this.g, b: this.b }); 522 | } 523 | luma(): number { 524 | // Source: https://www.w3.org/TR/WCAG20/#relativeluminancedef 525 | let r = this.r / 255.0; 526 | let g = this.g / 255.0; 527 | let b = this.b / 255.0; 528 | r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); 529 | g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); 530 | b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); 531 | 532 | return 0.2126 * r + 0.7152 * g + 0.0722 * b; 533 | } 534 | hex(): string { 535 | let hexString = ( 536 | (1 << 24) + 537 | (clampByte(this.r) << 16) + 538 | (clampByte(this.g) << 8) + 539 | clampByte(this.b) 540 | ) 541 | .toString(16) 542 | .slice(1); 543 | 544 | if (this.a < 1.0) { 545 | hexString += ('00' + Math.round(this.a * 255).toString(16)).slice(-2); 546 | } 547 | 548 | // Compress hex from hex-6 or hex-8 to hex-3 or hex-4 if possible 549 | if ( 550 | hexString[0] === hexString[1] && 551 | hexString[2] === hexString[3] && 552 | hexString[4] === hexString[5] && 553 | hexString[6] === hexString[7] 554 | ) { 555 | hexString = 556 | hexString[0] + 557 | hexString[2] + 558 | hexString[4] + 559 | (this.a < 1.0 ? hexString[6] : ''); 560 | } 561 | 562 | return '#' + hexString; 563 | } 564 | rgb(): string { 565 | return `rgb(${roundTo(this.r, 2)}, ${roundTo(this.g, 2)}, ${roundTo( 566 | this.b, 567 | 2 568 | )}${this.a < 1.0 ? ', ' + roundTo(100 * this.a, 2) + '%' : ''})`; 569 | } 570 | hsl(): string { 571 | return `hsl(${this.h}deg, ${this.s}%, ${this.l}%, ${ 572 | this.a < 1.0 ? ', ' + roundTo(100 * this.a, 2) + '%' : '' 573 | })`; 574 | } 575 | css(): string { 576 | if (this.r === 0 && this.g === 0 && this.b === 0 && this.a === 0) 577 | return 'transparent'; 578 | if (this.a < 1) { 579 | return this.rgb(); 580 | } 581 | return this.hex(); 582 | } 583 | canonicalScalar(): number { 584 | return this.luma(); 585 | } 586 | equals(v: Value): boolean { 587 | return ( 588 | isColor(v) && 589 | this.r === v.r && 590 | this.g === v.g && 591 | this.b === v.b && 592 | this.a === v.a 593 | ); 594 | } 595 | } 596 | 597 | export function makeValueFrom(from: { 598 | type: () => ValueType; 599 | [key: string]: unknown; 600 | }): Value { 601 | switch (from.type()) { 602 | case 'color': 603 | return new Color(from as ColorInterface); 604 | case 'frequency': 605 | return new Frequency(from.value as number, from.unit as FrequencyUnit); 606 | case 'time': 607 | return new Time(from.value as number, from.unit as TimeUnit); 608 | case 'angle': 609 | return new Angle(from.value as number, from.unit as AngleUnit); 610 | case 'string': 611 | return new StringValue(from.value as string); 612 | case 'length': 613 | return new Length(from.value, from.unit as LengthUnit); 614 | case 'percentage': 615 | return new Percentage(from.value as number); 616 | case 'number': 617 | return new NumberValue(from.value as number); 618 | case 'array': 619 | return new ArrayValue((from.value as unknown[]).map(makeValueFrom)); 620 | default: 621 | console.error('Unknown value type'); 622 | } 623 | return undefined; 624 | } 625 | export function isColor(arg: Value): arg is Color { 626 | return arg instanceof Color; 627 | } 628 | 629 | /** 630 | * Convert a value to a color object. 631 | * 632 | * @param {object|string} value - hex string, color name or object with partial 633 | * 634 | */ 635 | 636 | export function asColor(value: Record | string): Color { 637 | if (!value) return undefined; 638 | let result: Color; 639 | try { 640 | result = new Color(value); 641 | } catch (_err) { 642 | result = undefined; 643 | } 644 | return result; 645 | } 646 | 647 | export function isColorArray(arg: Value): arg is ArrayValue { 648 | return arg instanceof ArrayValue && arg.value.every((x) => isColor(x)); 649 | } 650 | 651 | export function isNumber(arg: Value): arg is NumberValue { 652 | return arg instanceof NumberValue; 653 | } 654 | 655 | export function assertNumber(arg: Value): asserts arg is NumberValue { 656 | console.assert(arg instanceof NumberValue); 657 | } 658 | 659 | function assertNumberOrPercentage(arg: Value): asserts arg is NumberValue { 660 | console.assert(arg instanceof NumberValue || arg instanceof Percentage); 661 | } 662 | 663 | export function assertLength(arg: Value): asserts arg is Length { 664 | console.assert(arg instanceof Length); 665 | } 666 | 667 | export function isPercentage(arg: Value): arg is Percentage { 668 | return arg instanceof Percentage; 669 | } 670 | 671 | export function isLength(arg: Value): arg is Length { 672 | return arg instanceof Length; 673 | } 674 | 675 | export function isString(arg: Value): arg is StringValue { 676 | return arg instanceof StringValue; 677 | } 678 | 679 | export function isAngle(arg: Value): arg is Angle { 680 | return arg instanceof Angle; 681 | } 682 | 683 | export function isTime(arg: Value): arg is Time { 684 | return arg instanceof Time; 685 | } 686 | 687 | export function isFrequency(arg: Value): arg is Frequency { 688 | return arg instanceof Frequency; 689 | } 690 | 691 | export function isArray(arg: Value): arg is ArrayValue { 692 | return arg instanceof ArrayValue; 693 | } 694 | 695 | export function isZero(arg: Value): arg is NumberValue { 696 | return arg instanceof NumberValue && arg.value === 0; 697 | } 698 | 699 | export function asInteger(value: Value, defaultValue?: number): number { 700 | if (isNumber(value)) { 701 | return Math.round(value.value); 702 | } 703 | if (typeof defaultValue === 'undefined') assertNumber(value); 704 | return defaultValue; 705 | } 706 | 707 | export function asDecimalRatio( 708 | value: Value, 709 | defaultValue?: number | null 710 | ): number { 711 | if (isPercentage(value)) { 712 | return value.value / 100; 713 | } else if (isNumber(value)) { 714 | return value.value; 715 | } 716 | 717 | if (typeof defaultValue === 'undefined') assertNumberOrPercentage(value); 718 | return defaultValue; 719 | } 720 | 721 | export function asDegree(value: Value): number { 722 | // https://drafts.csswg.org/css-values-3/#angle-value 723 | 724 | if (isAngle(value)) { 725 | if (value.unit === 'deg') { 726 | return value.value; 727 | } else if (value.unit === 'rad') { 728 | return (value.value * 180) / Math.PI; 729 | } else if (value.unit === 'grad') { 730 | return (value.value * 180) / 200; 731 | } else if (value.unit === 'turn') { 732 | return value.value * 360.0; 733 | } 734 | throwError(ErrorCode.UnknownUnit, value.unit); 735 | } else { 736 | assertNumber(value); 737 | // Degree is the canonical unit for angles 738 | return value.value; 739 | } 740 | } 741 | 742 | function asPx( 743 | value: number | MultiLength, 744 | unit: LengthUnit, 745 | baseUnits?: BaseLengthUnits 746 | ): number { 747 | // See https://drafts.csswg.org/css-values-3/#lengths 748 | if (typeof value !== 'number') { 749 | console.assert(unit === 'multi'); 750 | let pxSum = value['px'] ?? 0; 751 | Object.keys(value).forEach((x) => { 752 | const inPx = asPx(this.value[x], x as LengthUnit, baseUnits); 753 | if (isNaN(inPx)) return NaN; 754 | pxSum += pxSum; 755 | }); 756 | return pxSum; 757 | } 758 | if (unit === 'px') { 759 | return value; 760 | } else if (unit === 'cm') { 761 | return (value * 96.0) / 2.54; 762 | } else if (unit === 'mm') { 763 | return (value * 96.0) / 25.4; 764 | } else if (unit === 'Q') { 765 | return (value * 96.0) / 2.54 / 40.0; 766 | } else if (unit === 'in') { 767 | return value * 96.0; 768 | } else if (unit === 'pc') { 769 | return value * 16.0; 770 | } else if (unit === 'pt') { 771 | return (value * 96.0) / 72.0; 772 | } 773 | let base: number; 774 | if (unit === 'vmin') { 775 | base = Math.min(baseUnits?.vh ?? NaN, baseUnits?.vw ?? NaN); 776 | } else if (unit === 'vmax') { 777 | base = Math.max(baseUnits?.vh ?? NaN, baseUnits?.vw ?? NaN); 778 | } else { 779 | base = baseUnits?.[unit] ?? NaN; 780 | } 781 | 782 | return base * value; 783 | } 784 | 785 | export function asPercent(value: Value): number { 786 | if (isPercentage(value)) { 787 | return value.value / 100; 788 | } 789 | assertNumber(value); 790 | return value.value; 791 | } 792 | 793 | export function asString(value: Value, defaultValue: string): string { 794 | if (!isString(value)) { 795 | return defaultValue; 796 | } 797 | return value.value; 798 | } 799 | 800 | export function compareValue(a: Value, b: Value): number { 801 | // @todo: compare strings (asCanonicalString()) 802 | return b.canonicalScalar() - a.canonicalScalar(); 803 | } 804 | 805 | export function promoteToMulti(value: Length | NumberValue): Length { 806 | if (isNumber(value)) { 807 | return new Length({ px: value.value }, 'multi'); 808 | } 809 | if (value.unit === 'multi') return value; 810 | 811 | const newValue: MultiLength = {}; 812 | newValue[value.unit] = value.value; 813 | 814 | return new Length(newValue, 'multi'); 815 | } 816 | 817 | export function scaleLength(arg1: Length, arg2?: Value): ArrayValue { 818 | if (arg1.unit === 'multi') { 819 | throw new SyntaxError(ErrorCode.InvalidOperand); 820 | } 821 | const scaleName = asString(arg2, 'pentatonic').toLowerCase(); 822 | let scale = { 823 | 'tritonic': [2, 3], 824 | 'tetratonic': [2, 4], 825 | 'pentatonic': [2, 5], 826 | 'golden': [1.618, 1], 827 | 'golden ditonic': [1.618, 2], 828 | }[scaleName]; 829 | if (typeof scale === 'undefined') { 830 | // Try to parse the scale as "p:q" 831 | scale = scaleName.split(':').map((x) => parseFloat(x)); 832 | if (isNaN(scale[0]) || isNaN(scale[1])) { 833 | throw new SyntaxError(ErrorCode.InvalidOperand); 834 | } 835 | scale = [scale[1] / scale[0], 1]; 836 | } 837 | const [r, n] = scale; 838 | const range = 839 | (arg1.value as number) * (Math.pow(r, 7 / n) - Math.pow(r, -2 / n)); 840 | const precision = 841 | range < 10 || (arg1.value as number) * Math.pow(r, -2 / n) < 1 ? 2 : 0; 842 | const result: Value[] = [-2, -1, 0, 1, 2, 3, 4, 5, 6, 7].map( 843 | (i: number): Value => 844 | new Length( 845 | roundTo((arg1.value as number) * Math.pow(r, i / n), precision), 846 | arg1.unit 847 | ) 848 | ); 849 | 850 | return new ArrayValue(result); 851 | } 852 | -------------------------------------------------------------------------------- /test/token.test.js: -------------------------------------------------------------------------------- 1 | const chromatic = require('../bin/chromatic.js'); 2 | 3 | function c(s, options = {}) { 4 | return chromatic('./test/tokens/' + s + '.yaml', { 5 | header: '', 6 | console: 'log', 7 | ignoreErrors: true, 8 | ...options, 9 | }); 10 | } 11 | 12 | const testFiles = { 13 | 'simple': 'evaluates a simple token file', 14 | 'no-tokens': 'evaluates a file with no tokens', 15 | 'token-array': 'evaluates a file with an array of tokens', 16 | 'invalid-token-name': 'evaluates a token file with an invalid token name', 17 | 'expressions': 'evaluates expressions in token values correctly', 18 | 'angle': 'evaluates angles correctly', 19 | 'colors': 'evaluates color tokens correctly', 20 | 'aliases': 'evaluates aliases in token values correctly', 21 | 'metadata': 'evaluates comments, etc... associated with a token', 22 | 'theme': 'evaluates two themes', 23 | 'array': 'evaluates arrays', 24 | 'length': 'evaluates lengths', 25 | 'theme-propagation': 'propagate theme values', 26 | 'errors': 'handles syntax errors', 27 | }; 28 | 29 | Object.keys(testFiles).forEach((x) => { 30 | it(testFiles[x], () => { 31 | expect(c(x)).toMatchSnapshot(); 32 | }); 33 | }); 34 | 35 | it('generates a style guide', () => { 36 | expect( 37 | c('../../examples/advanced/tokens', { format: 'html' }) 38 | ).toMatchSnapshot(); 39 | }); 40 | 41 | it('generates a Sass stylesheet', () => { 42 | expect(c('basic-example/tokens', { format: 'sass' })).toMatchSnapshot(); 43 | }); 44 | 45 | it('generates a JSON file', () => { 46 | expect(c('basic-example/tokens', { format: 'json' })).toMatchSnapshot(); 47 | }); 48 | -------------------------------------------------------------------------------- /test/tokens/aliases.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | rouge: 'red' 3 | simple-alias__red: '{rouge}' 4 | two-level-alias__red: '{simple-alias__red}' 5 | alias-in-expression__e60000: 'darken({rouge})' 6 | alias-in-string__3px_f00: '3px {rouge}' 7 | invalid: 8 | circular-alias: 'darken({invalid.circular-alias})' 9 | two-level-circular-alias: '{invalid.one-level-circular-alias}' 10 | one-level-circular-alias: '{invalid.two-level-circular-alias}' 11 | mispelled-alias: 'darken({rogue})' 12 | alias-syntax-error: 'darken({rouge}}' # has a closing "}" instead of the expected ")" 13 | -------------------------------------------------------------------------------- /test/tokens/angle.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | canonical: '45deg' 3 | rad__55deg: '0.785398rad + 10deg' 4 | grad__55deg: '50grad + 10deg' 5 | turn__100deg: '(1/4)turn + 10deg' 6 | add__45deg: '40deg + 5deg' 7 | substract__45deg: '90deg - 50grad' 8 | divide-by-number__45deg: '90deg / 2' 9 | divide__2: '90deg / 45deg' 10 | multiply-by-number-right__15deg: '3deg * 5' 11 | multiply-by-number-left__15deg: '5 * 3deg' 12 | invalid: 13 | number-plus-angle: '42 + 10deg' 14 | add-color: '3deg + #fff' 15 | multiply-deg: '3deg * 5rad' 16 | percent-plus-angle: '10% + 30deg' 17 | angle-plus-number: '10deg + 5' 18 | angle-plus-percent: '10deg + 10%' 19 | angle-plus-color: '10deg + #fa7' 20 | angle-plus-dimen: '10deg + 10px' 21 | -------------------------------------------------------------------------------- /test/tokens/array.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | primary: '#0066ce' 3 | auto-scaling__80b3e7: '{primary-200}' 4 | array: '[blue, white, red]' 5 | array-literal__f0f: '[yellow, magenta, cyan][1]' 6 | array-index__00f: '{array}[0]' 7 | array-index-expression_fff: '{array}[ 24 / 12 - 1 ]' 8 | manual-scaling__80b3e7: 'scale({primary})[2]' 9 | invalid: 10 | unterminated-array-index: '{array}[1+2' 11 | array-index-multiple-arguments: '{array}[2, 4 ]' 12 | array-index-length: '{array}[2px]' 13 | array-index-string: '{array}["0"]' 14 | index-of-float: '12[2]' 15 | index-of-string: '"hello"[2]' 16 | -------------------------------------------------------------------------------- /test/tokens/basic-example/base-colors.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | red: 'hsl(2.2,67.9%,52.4%) ' 3 | orange: 'hsl(38.8,100%,50%)' 4 | yellow: 'hsl(48,100%,50%)' 5 | green: 'hsl(85.1,41.1%,35.3%)' 6 | blue: 'hsl(208.1,69.6%,45.1%)' 7 | purple: 'hsl(279.9,68%,59.6%)' 8 | 9 | primary-hue: '210deg' 10 | primary: 'hsl({primary-hue}, 90%, 40%)' 11 | 12 | volcano: '#FFEB3B' 13 | volcano-100: 'scale({volcano})[1]' 14 | volcano-200: 'scale({volcano})[2]' 15 | volcano-300: 'scale({volcano})[3]' 16 | volcano-400: 'scale({volcano})[4]' 17 | volcano-500: 'scale({volcano})[5]' 18 | volcano-600: 'scale({volcano})[6]' 19 | volcano-700: 'scale({volcano})[7]' 20 | volcano-800: 'scale({volcano})[8]' 21 | volcano-900: 'scale({volcano})[9]' 22 | 23 | semantic: 24 | success: 25 | comment: 'Indicate a completed task.' 26 | value: '{green}' 27 | warning: 28 | comment: 'Indicate a problem that does not prevent a task from being completed.' 29 | value: '{orange}' 30 | error: 31 | comment: 'Indicate an error that prevents a task from being completed.' 32 | value: '{red}' 33 | -------------------------------------------------------------------------------- /test/tokens/basic-example/dark-theme.yaml: -------------------------------------------------------------------------------- 1 | theme: 'dark' 2 | tokens: 3 | semantic: 4 | error: 'lighten(saturate({semantic.error}, 20%), 8%)' 5 | page-background: 'gray(15%)' 6 | text-color: 'gray(95%)' 7 | -------------------------------------------------------------------------------- /test/tokens/basic-example/tokens.yaml: -------------------------------------------------------------------------------- 1 | import: 2 | - ./base-colors.yaml 3 | - ./dark-theme.yaml 4 | tokens: 5 | scrim: 'gray(30%, 40%)' 6 | cta-button-background: '{primary}' 7 | page-background: 'gray(98%)' 8 | text-color: 'gray(5%)' 9 | cta-button-color: 'contrast({primary}, #ddd, #333)' 10 | link-color: '{blue}' 11 | link-active-color: 'saturate(darken({link-color}, 5%), 15%)' 12 | link-down-color: 'darken({link-color}, 10%)' 13 | link-hover-color: 'saturate(darken({link-color}, 5%), 10%)' 14 | warm-grey: mix(gray(50%), red, 6%) 15 | cool-grey: mix(gray(50%), green, 6%) 16 | -------------------------------------------------------------------------------- /test/tokens/colors.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | rgb-decimal-1: 'rgb(255 160 122 .5)' 3 | hex-3: '#fa7' 4 | hex-4: '#fa78' 5 | hex-6: '#ffa07a' 6 | hex-8: '#ffa07a80' 7 | hex-6-uppercase: '#FFA07A80' 8 | named: 'salmon' 9 | named-uppercase: 'Salmon' 10 | transparent: 'transparent' 11 | current: 'currentcolor' 12 | complex: 'darken(lab(54.975%, 36.8, -50.097))' 13 | transparent-as-rgb: 'rgb(0,0,0,0)' 14 | rgb-decimal: 'rgb(255 160 122 .5)' 15 | rgb-percent: 'rgb(99.5% 62.7% 47.8% 50%)' 16 | rgb-clamped-alpha: 'rgb(255 160 122 2.5)' 17 | rgb-alpha-percent: 'rgb(255 160 122 50%)' 18 | rgb-no-alpha: 'rgb(255 160 122)' 19 | rgba: 'rgba(255 160 122 50%)' 20 | rgb-legacy-syntax: 'rgba(255, 160, 122, 50%)' 21 | hsl: 'hsl(17.1deg 100% 73.9% .5)' 22 | hsl-decimal-deg: 'hsl(17.1 100% 73.9% .5)' 23 | hsl-decimal: 'hsl(17.1 1.0 0.74 .5)' 24 | hsl-legacy-syntax: 'hsl(17.1deg, 100%, 73.9%, .5)' 25 | hsla: 'hsla(17.1deg 100% 73.9% .5)' 26 | hsl-with-grad: 'hsla(19grad 100% 73.9% .5)' 27 | hsl-with-rad: 'hsla(0.2985rad 100% 73.9% .5)' 28 | 29 | lab-color-conversion_0066ce: 'lab(44%, 16, -60)' 30 | lab-color-conversion_000: 'lab(0, 0, 0)' 31 | lab-color-conversion_fff: 'lab(100%, 0, 0)' 32 | lab-color-conversion_777: 'lab(50%, 0, 0)' 33 | lab-color-conversion_ff89ff: 'lab(100%, 128, -128)' 34 | lab-color-conversion-clamped_ff89ff: 'lab(200%, 200, -200)' 35 | hwb: 'hwb(17deg 48% 0%)' 36 | hsv: 'hsv(259.6deg, 48.9%, 85.9%)' # #9370db 37 | hsv-with-unicode-deg: 'hsv(259.6° 48.9% 85.9%)' # #9370db 38 | lab: 'lab(54.975%, 36.8, -50.097)' 39 | gray: 'gray(50%)' 40 | rotate-hue: 'rotateHue(#ffa07a, 45deg)' 41 | rotate-hue-single-argument: 'rotateHue(#ffa07a)' 42 | complement-1: 'complement(#ffa07a)' 43 | complement: 'complement(#d2e1dd)' #e1d2d6 44 | mix-white: 'mix(#fff, #d2e1dd, 60%)' 45 | mix-black: 'mix(#000, #d2e1dd, 60%)' 46 | darken: 'darken(#d2e1dd, 33%)' # reduces lightness by 33% 47 | lighten: 'lighten(#d2e1dd, 33%)' # increase lightness by 33% 48 | shade: 'shade(#d2e1dd, 33%)' # mix with black 49 | tint: 'tint(#d2e1dd, 33%)' # mix with white 50 | tint-default: 'tint(#d2e1dd)' # mix wiht white 51 | tint-10: 'tint(#d2e1dd, 10%)' # mix wiht white 52 | saturate: 'saturate(#8060c0 33%)' # = #d42b2b increase saturation by 1/3 #df2020 53 | desaturate: 'desaturate(#8060c0 50%)' # decrease saturation by 1/2 54 | saturate-desaturate: 'desaturate(saturate(#8060c0 20%) 20%)' 55 | contrast-purple: 'contrast(#fff, #CC21CC, #f00)' 56 | contrast-auto: 'contrast(#CC21CC)' 57 | string-argument: 'mix(#fff, #d2e1dd, 60%, "hsl")' 58 | invalid: 59 | argument-alias: 'tint({hex-6}, {named})' 60 | argument: 'tint(#f00, #0f0)' 61 | not-enough-arguments: 'min(56)' 62 | too-many-arguments: 'min(56, 42, 33)' 63 | missing-arguments: 'rgb(255)' 64 | rotate-hue-lowercase: 'rotatehue(#ffa07a, 45deg)' 65 | number-plus-percent: '42 + 10%' 66 | number-plus-color: '42 + #fa7' 67 | number-plus-dimen: '42 + 10px' 68 | percent-plus-number: '10% + 5' 69 | percent-plus-color: '10% + #fa7' 70 | percent-plus-dimen: '10% + 10px' 71 | color-plus-number: '#fa7 + 2' 72 | color-plus-angle: '#fa7 + 10deg' 73 | color-plus-percent: '#fa7 + 10%' 74 | color-plus-color: '#fa7 + #123' 75 | color-plus-dimen: '#fa7 + 12px' 76 | mix-invalid-argument: 'mix(#fa7, #000, .5, "lob")' 77 | -------------------------------------------------------------------------------- /test/tokens/errors.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | array: '[3, 5, 7, 13]' 3 | circular-token: '{circular-token}' 4 | inconsistent-token: 5 | value: 6 | dark: '#ff0' 7 | light: 2px 8 | expect-color: 'contrast(12px)' 9 | unknown-unit: '12km' 10 | missing-argument: 'min(12)' 11 | unexpected-argument: 'min(#ff0, #f0f)' 12 | too-many-arguments: 'min(3, 4, 5)' 13 | unexpected-open-bracket: '12px[0]' 14 | unclosed-array-index: '{array}[0' 15 | unclosed-array-literal: '[3, 5, 7' 16 | array-syntax-error: '[3;5]' 17 | string-index: '{array}["0"]' 18 | unclosed-string: '"hello' 19 | unknown-function: 'foo(12)' 20 | arg-list-syntax-error: 'min(12;14)' 21 | expected-operand-term: '12+' 22 | expected-operand-prod: '12*' 23 | invalid-operand-prod: '(#fff/2)+15px' 24 | invalid-unary-operand: '-#fff' 25 | invalid-operand-term: '10%+"hello"' 26 | unterminated-group: '(1+5' 27 | -------------------------------------------------------------------------------- /test/tokens/expressions.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | string: 'boom' 3 | length__12px: '12px' 4 | product-mul__15px: '3 * 5px' 5 | product-mul-multi__30px: '3 * 2 * 5px' 6 | product-div__9pt: '18pt / 2' 7 | term-add: '18pt + 2px' 8 | term-add-multi: '18px + 2px + 10pt' 9 | term-add-multi-angle: '10deg + 5deg + 2deg' 10 | term-sub: '36pt - 10px' 11 | term-zero__36pt: '36pt - 0px' 12 | term-product__13: '5 * 2 + 3' 13 | term-product-2__13: '3 + 5 * 2' 14 | unary-minus__-17: '-17' 15 | unary-plus__17: '+17' 16 | string-plus-string-2: '42 + " is the answer"' 17 | number-plus-number__42: '22 + 20' 18 | angle-plus-angle: '10deg + 5rad' 19 | add-angles-same-unit__18deg: '10deg + 3deg + 5deg' 20 | angle-plus-string: '10deg + " slope"' 21 | percent-plus-percent: '10% + 30%' 22 | percent-plus-string: '10% + " discount"' 23 | color-plus-string: '#fa7 + " color"' 24 | dimen-plus-dimen: '12px + 5pt' 25 | string-plus-number: '"answer: " + 42' 26 | string-plus-angle: '"hello " + 12deg' 27 | string-plus-percent: '"hello " + 10%' 28 | string-plus-color: '"hello " + #f7a' 29 | string-plus-dimen: '"hello " + 12px' 30 | string-plus-string: '"hello " + " world"' 31 | product-angle__20deg: '2 * 10deg' 32 | product-percent__30percent: '2 * 15%' 33 | product-length__18pt: '1.5 * 12pt' 34 | divide-angle__5deg: '10deg / 2' 35 | divide-percent__25percent: '75% / 3' 36 | divide-length__5px: '15px / 3' 37 | group-percent__33-percent: '(100/3)%' 38 | group-length__15px: '(10 + 5)px' 39 | group__25: '5 * (2 + 3)' 40 | assoc-multi__30: '5 * 2 * 3' 41 | assoc-sub__2: '7 - 3 - 2' 42 | length-ratio: '100px / 12pt' 43 | color-ratio__008: '#333 / #aaa' 44 | ws-in-expression__25: ' 5 * ( 2 + 3 ) ' 45 | no-ws-in-expression__25: '5*(2+3)' 46 | ws-in-function: ' mix (#f45 , #324 , 34% ) ' 47 | zero-add-to-length: 12pt + 0 48 | zero-add-to-time: 1s + 0 49 | zero-add-to-angle: 23deg + 0 50 | zero-add-to-frequency: 50hz + 0 51 | invalid: 52 | missing-right-operand: '3 + (5 +' 53 | missing-close-paren: '3 + (5 + 2' 54 | string-unary-neg: '-"hello"' 55 | -------------------------------------------------------------------------------- /test/tokens/invalid-token-name.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | '<>': '#ff0000' # Invalid token name 3 | -------------------------------------------------------------------------------- /test/tokens/length.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | length-cm: '1cm + 1px' 3 | length-mm: '1mm + 1px' 4 | length-Q: '1Q + 1px' 5 | length-in: '1in + 1px' 6 | length-pc: '1pc + 1px' 7 | length-pt: '1pt + 1px' 8 | length-px: '1px + 0px' 9 | divide-two-lengths-different-relative-unit__NaN: '2em / 5rem' 10 | divide-two-lengths-same-relative-units__2: '4em / 2em' 11 | multiply-two-lengths__throw: '2em * 5rem' 12 | product-mul__15px: '3 * 5px' 13 | add-px__calc-2em-plus-15px: '2em + 3px + 5px' 14 | term-zero__36pt: '36pt - 0px' 15 | term-zero-relative-unit__2em: '2em - 0px' 16 | length-cm__38-8px: '1cm + 1px' 17 | divide-two-lengths-same-unit__2: '10em / 5em' 18 | divide-two-lengths-absolute-units__7-5: '20px / 2pt' 19 | substract-to-0__0: 2em - 2em 20 | two-em-and-3ex__calc: '2em + 3ex' 21 | two-em-and-3px__calc: '2em + 3px' 22 | add-px-alias__calc__2em-plus-8px: '{two-em-and-3px__calc} + 5px' 23 | add-em__calc-4em-plus-3px: '{two-em-and-3px__calc} + 2em' 24 | multiply-lhs-calc-4em-plus-6px: '2 * {two-em-and-3px__calc}' 25 | multiply-rhs-calc-4em-plus-6px: '{two-em-and-3px__calc} * 2' 26 | substract-to-absolute__3px: '{two-em-and-3px__calc} - 2em' 27 | dimen-plus-number: '12px + 5' 28 | dimen-plus-angle: '12px + 10deg' 29 | dimen-plus-percent: '12px + 5%' 30 | length-rem: '1rem + 0px' 31 | length-em: '1em + 0px' 32 | scale-default: 'scale(12pt)' 33 | scale-pentatonic: 'scale(1em, "pentatonic")' 34 | scale-golden: 'scale(1em, "golden")' 35 | scale-5-2: 'scale(1em, "5:8")' 36 | body: '16px' 37 | scaled-body-heading: '{body-600}' 38 | scaled-body-tiny: '{body-50}' 39 | invalid: 40 | scale-multi: 'scale(1em + 10px)' 41 | unknown-unit: '12km' 42 | dimen-plus-color: '12px + #f7a' 43 | -------------------------------------------------------------------------------- /test/tokens/metadata.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | primary: 3 | comment: 'Use sparingly' 4 | remarks: > 5 | Here's to the crazy ones. 6 | The misfits. 7 | The rebels. 8 | The troublemakers. 9 | The **round pegs** in the _square holes_. 10 | 11 | The ones who see things differently. 12 | 13 | They're not fond of rules. 14 | And they have no respect for the status quo. 15 | 16 | You can quote them, disagree with them, 17 | glorify or vilify them. 18 | About the only thing you can't do is ignore them. 19 | 20 | Because they change things. 21 | 22 | They push the human race forward. 23 | 24 | While some may see them as the crazy ones, 25 | we see genius. 26 | 27 | Because the people who are crazy enough to think 28 | they can change the world, are the ones who do. 29 | 30 | value: 'hsl(230, 100%, 50%)' 31 | -------------------------------------------------------------------------------- /test/tokens/no-tokens.yaml: -------------------------------------------------------------------------------- 1 | red: '#f00' 2 | green: '#0F0' 3 | -------------------------------------------------------------------------------- /test/tokens/simple.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | primary__0066ce: '#0066ce' 3 | -------------------------------------------------------------------------------- /test/tokens/theme-propagation.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | single: '#00F' 3 | base: 4 | value: 5 | _: '#f00' 6 | green: '#0f0' 7 | first-level: '{base}' 8 | second-level: 'darken({first-level})' 9 | first-level-single: '{single}' 10 | -------------------------------------------------------------------------------- /test/tokens/theme.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | background: 3 | value: 4 | dark: '#fff' 5 | light: '#333' 6 | body: 7 | value: 8 | dark: '#222' 9 | light: '#ddd' 10 | -------------------------------------------------------------------------------- /test/tokens/token-array.yaml: -------------------------------------------------------------------------------- 1 | tokens: 2 | - red: '#f00' 3 | - green: '#0f0' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "allowJs": true, 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "incremental": true, 9 | "module": "ESNext", 10 | "moduleResolution": "node", 11 | // "newLine": "lf", 12 | "noImplicitAny": false, 13 | "noLib": false, 14 | "removeComments": true, 15 | "sourceMap": true, 16 | // "strictNullChecks": true, 17 | "target": "es2019", 18 | 19 | "lib": ["es2017", "dom", "dom.iterable", "scripthost"] 20 | }, 21 | "exclude": ["node_modules", "**/*.spec.ts", "bin"] 22 | } 23 | --------------------------------------------------------------------------------