├── index.js ├── .github ├── FUNDING.yml ├── workflows │ └── ci.yml └── stale.yml ├── .npmignore ├── .editorconfig ├── test ├── runner.mjs ├── utils.test.mjs ├── utility-classes.test.mjs └── custom-properties.test.mjs ├── lib ├── utils.js └── design-token-utils.js ├── package.json ├── LICENSE ├── .gitignore └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./lib/design-token-utils.js"); 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: saneef 2 | ko_fi: saneef 3 | patreon: saneef 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .eslintcache 3 | .eslintrc.js 4 | .github/ 5 | .gitattributes 6 | test 7 | lib/**/*.test.js 8 | .nyc_output 9 | coverage 10 | *.tgz -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = false 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | [*.js] 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /test/runner.mjs: -------------------------------------------------------------------------------- 1 | import cssnano from "cssnano"; 2 | import postcss from "postcss"; 3 | import plugin from "../index.js"; 4 | 5 | export async function run(input, options) { 6 | return postcss([ 7 | plugin(options), 8 | cssnano({ 9 | preset: ["lite"], 10 | }), 11 | ]).process(input, { 12 | from: "test.css", 13 | }); 14 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - "gh-pages" 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 11 | node: ["16", "18", "20"] 12 | name: Node.js ${{ matrix.node }} on ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: ${{ matrix.node }} 19 | - run: npm install 20 | - run: npm test 21 | env: 22 | YARN_GPG: no 23 | -------------------------------------------------------------------------------- /test/utils.test.mjs: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { parseAtRuleParams } from "../lib/utils.js"; 3 | 4 | test("parseAtRuleParams: parses with no arguments", async (t) => { 5 | const res = parseAtRuleParams("(custom-properties)"); 6 | t.is(res, undefined); 7 | }); 8 | 9 | test("parseAtRuleParams: parses with one argument", async (t) => { 10 | const res = parseAtRuleParams("(custom-properties: colors)"); 11 | t.deepEqual(res, ["colors"]); 12 | }); 13 | 14 | test("parseAtRuleParams: parses with more than one arguments", async (t) => { 15 | const res = parseAtRuleParams("(custom-properties: space,font-size)"); 16 | t.deepEqual(res, ["space", "font-size"]); 17 | }); 18 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse @rule params 3 | * 4 | * @example 5 | * // returns undefined 6 | * parseAtRuleParams("(custom-properties)") 7 | * @example 8 | * // returns ["one","two"] 9 | * parseAtRuleParams("(custom-properties: one, two)") 10 | * 11 | * @param {string} paramString The parameter string 12 | * @param {string} [argName="custom-properties"] The argument name 13 | * @return {Array|undefined} 14 | */ 15 | function parseAtRuleParams(paramString, argName = "custom-properties") { 16 | // Removes any spaces 17 | const str = paramString.replace(/\s/g, ""); 18 | 19 | const start = `(${argName}:`; 20 | const end = ")"; 21 | if (str.startsWith(start) && str.endsWith(end)) { 22 | const args = str.replace(start, "").replace(end, ""); 23 | if (args.length) return args.split(","); 24 | } 25 | } 26 | 27 | module.exports = { 28 | parseAtRuleParams, 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-design-token-utils", 3 | "version": "3.0.1", 4 | "description": "PostCSS plugin to convert design tokens to CSS custom properties and utility classes.", 5 | "main": "index.js", 6 | "funding": "https://github.com/sponsors/saneef/", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/saneef/postcss-design-token-utils.git" 10 | }, 11 | "scripts": { 12 | "test": "ava" 13 | }, 14 | "keywords": [ 15 | "postcss-plugin", 16 | "design-tokens", 17 | "utility-class", 18 | "atomic-css", 19 | "postcss" 20 | ], 21 | "author": "Saneef Ansari (https://saneef.com/)", 22 | "license": "MIT", 23 | "dependencies": { 24 | "just-kebab-case": "^4.2.0" 25 | }, 26 | "devDependencies": { 27 | "ava": "^6.1.2", 28 | "cssnano": "^7.0.0", 29 | "cssnano-preset-lite": "^4.0.0", 30 | "postcss": "^8.4.38" 31 | }, 32 | "peerDependencies": { 33 | "postcss": "^8.0.0" 34 | }, 35 | "ava": { 36 | "files": [ 37 | "test/**/*", 38 | "!test/runner.mjs" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Saneef Ansari 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | # Logs 29 | logs 30 | *.log 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | lerna-debug.log* 35 | .pnpm-debug.log* 36 | 37 | # Diagnostic reports (https://nodejs.org/api/report.html) 38 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 39 | 40 | # Runtime data 41 | pids 42 | *.pid 43 | *.seed 44 | *.pid.lock 45 | 46 | # Directory for instrumented libs generated by jscoverage/JSCover 47 | lib-cov 48 | 49 | # Coverage directory used by tools like istanbul 50 | coverage 51 | *.lcov 52 | 53 | # nyc test coverage 54 | .nyc_output 55 | 56 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 57 | .grunt 58 | 59 | # Bower dependency directory (https://bower.io/) 60 | bower_components 61 | 62 | # node-waf configuration 63 | .lock-wscript 64 | 65 | # Compiled binary addons (https://nodejs.org/api/addons.html) 66 | build/Release 67 | 68 | # Dependency directories 69 | node_modules/ 70 | jspm_packages/ 71 | 72 | # Snowpack dependency directory (https://snowpack.dev/) 73 | web_modules/ 74 | 75 | # TypeScript cache 76 | *.tsbuildinfo 77 | 78 | # Optional npm cache directory 79 | .npm 80 | 81 | # Optional eslint cache 82 | .eslintcache 83 | 84 | # Optional stylelint cache 85 | .stylelintcache 86 | 87 | # Microbundle cache 88 | .rpt2_cache/ 89 | .rts2_cache_cjs/ 90 | .rts2_cache_es/ 91 | .rts2_cache_umd/ 92 | 93 | # Optional REPL history 94 | .node_repl_history 95 | 96 | # Output of 'npm pack' 97 | *.tgz 98 | 99 | # Yarn Integrity file 100 | .yarn-integrity 101 | 102 | # dotenv environment variable files 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | .cache 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | .next 115 | out 116 | 117 | # Nuxt.js build / generate output 118 | .nuxt 119 | dist 120 | 121 | # Gatsby files 122 | .cache/ 123 | # Comment in the public line in if your project uses Gatsby and not Next.js 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | # public 126 | 127 | # vuepress build output 128 | .vuepress/dist 129 | 130 | # vuepress v2.x temp and cache directory 131 | .temp 132 | .cache 133 | 134 | # Docusaurus cache and generated files 135 | .docusaurus 136 | 137 | # Serverless directories 138 | .serverless/ 139 | 140 | # FuseBox cache 141 | .fusebox/ 142 | 143 | # DynamoDB Local files 144 | .dynamodb/ 145 | 146 | # TernJS port file 147 | .tern-port 148 | 149 | # Stores VSCode versions used for testing VSCode extensions 150 | .vscode-test 151 | 152 | # yarn v2 153 | .yarn/cache 154 | .yarn/unplugged 155 | .yarn/build-state.yml 156 | .yarn/install-state.gz 157 | .pnp.* 158 | 159 | pnpm-lock.yaml 160 | yarn.lock 161 | -------------------------------------------------------------------------------- /test/utility-classes.test.mjs: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | 3 | import { run } from "./runner.mjs"; 4 | 5 | test("Generate utility classes", async (t) => { 6 | const tokens = { color: { accent: "#ff0", dark: "#111" } }; 7 | const input = `@design-token-utils (utility-classes);`; 8 | const options = { 9 | utilityClasses: [{ id: "color", property: "color" }], 10 | }; 11 | const res = await run(input, { tokens, ...options }); 12 | t.is( 13 | res.css, 14 | `.color-accent{color:var(--color-accent)}.color-dark{color:var(--color-dark)}`, 15 | ); 16 | }); 17 | 18 | test("Generate with prefix", async (t) => { 19 | const tokens = { 20 | color: { accent: "#ff0", dark: "#111" }, 21 | textSize: { 22 | "step-0": "1rem", 23 | "step-1": "1.333rem", 24 | "step-2": "1.776rem", 25 | }, 26 | }; 27 | const input = `@design-token-utils (utility-classes);`; 28 | const options = { 29 | utilityClasses: [ 30 | { id: "color", property: "color", prefix: "text" }, 31 | { id: "textSize", property: "font-size", prefix: "" }, 32 | ], 33 | }; 34 | const res = await run(input, { tokens, ...options }); 35 | t.is( 36 | res.css, 37 | `.text-accent{color:var(--color-accent)}.text-dark{color:var(--color-dark)}.step-0{font-size:var(--text-size-step-0)}.step-1{font-size:var(--text-size-step-1)}.step-2{font-size:var(--text-size-step-2)}`, 38 | ); 39 | }); 40 | 41 | test("Generate with multiple properties", async (t) => { 42 | const tokens = { space: { m: "1rem", l: "2rem" } }; 43 | const input = `@design-token-utils (utility-classes);`; 44 | const options = { 45 | utilityClasses: [ 46 | { 47 | id: "space", 48 | prefix: "margin-y", 49 | property: ["margin-top", "margin-bottom"], 50 | }, 51 | ], 52 | }; 53 | const res = await run(input, { tokens, ...options }); 54 | t.is( 55 | res.css, 56 | `.margin-y-m{margin-top:var(--space-m);margin-bottom:var(--space-m)}.margin-y-l{margin-top:var(--space-l);margin-bottom:var(--space-l)}`, 57 | ); 58 | }); 59 | 60 | test("Generate with responsive variants", async (t) => { 61 | const tokens = { color: { accent: "#ff0" } }; 62 | const input = `@design-token-utils (utility-classes);`; 63 | const options = { 64 | breakpoints: { 65 | sm: "320px", 66 | md: "640px", 67 | }, 68 | utilityClasses: [ 69 | { 70 | id: "color", 71 | prefix: "text", 72 | property: "color", 73 | responsiveVariants: true, 74 | }, 75 | ], 76 | }; 77 | const res = await run(input, { tokens, ...options }); 78 | t.is( 79 | res.css, 80 | `.text-accent{color:var(--color-accent)}@media (min-width: 320px){.sm-text-accent{color:var(--color-accent)}}@media (min-width: 640px){.md-text-accent{color:var(--color-accent)}}`, 81 | ); 82 | }); 83 | 84 | test("Generate viewport variants with colon separated classes", async (t) => { 85 | const tokens = { color: { accent: "#ff0" } }; 86 | const input = `@design-token-utils (utility-classes);`; 87 | const options = { 88 | breakpoints: { 89 | sm: "320px", 90 | md: "640px", 91 | }, 92 | utilityClasses: [ 93 | { 94 | id: "color", 95 | prefix: "text", 96 | property: "color", 97 | responsiveVariants: true, 98 | }, 99 | ], 100 | responsivePrefixClassSeparator: "\\:", 101 | }; 102 | const res = await run(input, { tokens, ...options }); 103 | 104 | t.is( 105 | res.css, 106 | `.text-accent{color:var(--color-accent)}@media (min-width: 320px){.sm\\:text-accent{color:var(--color-accent)}}@media (min-width: 640px){.md\\:text-accent{color:var(--color-accent)}}`, 107 | ); 108 | }); -------------------------------------------------------------------------------- /test/custom-properties.test.mjs: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | 3 | import { run } from "./runner.mjs"; 4 | 5 | test("Skips when applied outside of a rule", async (t) => { 6 | const tokens = { color: { accent: "#ff0" } }; 7 | const input = `@design-token-utils (custom-properties);`; 8 | const res = await run(input, { tokens }); 9 | t.is(res.css, `@design-token-utils (custom-properties);`); 10 | }); 11 | 12 | test("Generates custom properties", async (t) => { 13 | const tokens = { color: { accent: "#ff0", primary: "#0ff" } }; 14 | const input = `:root { @design-token-utils (custom-properties); }`; 15 | const res = await run(input, { tokens }); 16 | t.is(res.css, ":root{--color-accent:#ff0;--color-primary:#0ff}"); 17 | }); 18 | 19 | test("Generates with number values", async (t) => { 20 | const tokens = { leading: { s: 1.1, m: 1.5, lg: 1.7 } }; 21 | const input = `:root { @design-token-utils (custom-properties); }`; 22 | const res = await run(input, { tokens }); 23 | t.is(res.css, ":root{--leading-s:1.1;--leading-m:1.5;--leading-lg:1.7}"); 24 | }); 25 | test("Generates with array values", async (t) => { 26 | const tokens = { 27 | fontFamily: [ 28 | "Inter", 29 | "Segoe UI", 30 | "Roboto", 31 | "Helvetica Neue", 32 | "Arial", 33 | "sans-serif", 34 | ], 35 | }; 36 | const input = `:root { @design-token-utils (custom-properties); }`; 37 | const res = await run(input, { tokens }); 38 | t.is( 39 | res.css, 40 | ":root{--font-family:Inter,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}", 41 | ); 42 | }); 43 | 44 | test("Generates with prefix properties", async (t) => { 45 | const tokens = { 46 | color: { accent: "#ff0" }, 47 | fontSize: { "step-0": "1rem", "step-1": "2rem" }, 48 | }; 49 | const input = `:root { @design-token-utils (custom-properties); }`; 50 | const res = await run(input, { 51 | tokens, 52 | customProperties: [ 53 | { id: "color", prefix: "c" }, 54 | { id: "fontSize", prefix: "" }, 55 | ], 56 | }); 57 | t.is(res.css, ":root{--c-accent:#ff0;--step-0:1rem;--step-1:2rem}"); 58 | }); 59 | 60 | test("Generates ungrouped properties when no groups specified", async (t) => { 61 | const tokens = { 62 | color: { accent: "#ff0" }, 63 | fontFamily: { base: "sans-serif", mono: "monospace" }, 64 | }; 65 | const input = `:root { @design-token-utils (custom-properties); }`; 66 | const res = await run(input, { 67 | tokens, 68 | customProperties: [{ id: "color" }, { id: "fontFamily", group: "font" }], 69 | }); 70 | t.is(res.css, ":root{--color-accent:#ff0}"); 71 | }); 72 | 73 | test("Generates all groups", async (t) => { 74 | const tokens = { 75 | color: { accent: "#ff0" }, 76 | fontFamily: { base: "sans-serif", mono: "monospace" }, 77 | }; 78 | const input = `:root { @design-token-utils (custom-properties: all); }`; 79 | const res = await run(input, { 80 | tokens, 81 | customProperties: [{ id: "color" }, { id: "fontFamily", group: "font" }], 82 | }); 83 | t.is( 84 | res.css, 85 | ":root{--color-accent:#ff0;--font-family-base:sans-serif;--font-family-mono:monospace}", 86 | ); 87 | }); 88 | 89 | test("Generates properties from a group", async (t) => { 90 | const tokens = { 91 | color: { accent: "#ff0" }, 92 | fontFamily: { base: "sans-serif", mono: "monospace" }, 93 | }; 94 | const input = `:root { @design-token-utils (custom-properties: font); }`; 95 | const res = await run(input, { 96 | tokens, 97 | customProperties: [{ id: "color" }, { id: "fontFamily", group: "font" }], 98 | }); 99 | t.is( 100 | res.css, 101 | ":root{--font-family-base:sans-serif;--font-family-mono:monospace}", 102 | ); 103 | }); 104 | 105 | test("Generates custom properties from nested tokens", async (t) => { 106 | const tokens = { 107 | color: { 108 | gray: { 100: "#f1f5f9", 800: "#1e293b" }, 109 | }, 110 | }; 111 | const input = `:root { @design-token-utils (custom-properties); }`; 112 | const res = await run(input, { 113 | tokens, 114 | customProperties: [{ id: "color.gray", prefix: "shade" }], 115 | }); 116 | t.is(res.css, ":root{--shade-100:#f1f5f9;--shade-800:#1e293b}"); 117 | }); 118 | 119 | test("Generates custom properties by group from nested tokens", async (t) => { 120 | const tokens = { 121 | color: { 122 | gray: { 100: "#f1f5f9", 800: "#1e293b" }, 123 | primary: { 100: "#dcfce7", 800: "#166534" }, 124 | }, 125 | }; 126 | const input = `:root { @design-token-utils (custom-properties: shades); }`; 127 | const res = await run(input, { 128 | tokens, 129 | customProperties: [{ id: "color.gray", group: "shades" }], 130 | }); 131 | t.is(res.css, ":root{--color-gray-100:#f1f5f9;--color-gray-800:#1e293b}"); 132 | }); -------------------------------------------------------------------------------- /lib/design-token-utils.js: -------------------------------------------------------------------------------- 1 | const kebabCase = require("just-kebab-case"); 2 | const { parseAtRuleParams } = require("./utils.js"); 3 | 4 | /** @type {import('postcss').PluginCreator} PluginCreator */ 5 | module.exports = ({ 6 | tokens = {}, 7 | customProperties = [], 8 | breakpoints = {}, 9 | utilityClasses = [], 10 | responsivePrefixClassSeparator = "-", 11 | } = {}) => { 12 | const prefixMap = new Map( 13 | customProperties 14 | .filter(({ id, prefix }) => id !== undefined && prefix !== undefined) 15 | .map(({ id, prefix }) => [id, prefix]), 16 | ); 17 | const groupMap = new Map( 18 | customProperties 19 | .filter(({ id, group }) => id !== undefined && group !== undefined) 20 | .map(({ id, group }) => [id, group]), 21 | ); 22 | const T = createCustomProperties(tokens, { prefixMap, groupMap }); 23 | const C = createUtilityClasses(T, utilityClasses); 24 | const V = Object.entries(breakpoints); 25 | 26 | return { 27 | postcssPlugin: "postcss-design-token-utils", 28 | AtRule: { 29 | "design-token-utils": function ( 30 | atRule, 31 | { Declaration, Rule, AtRule, result }, 32 | ) { 33 | insertCustomProperties(atRule, T, { Declaration }); 34 | 35 | insertUtilityClasses(atRule, C, V, { 36 | Declaration, 37 | Rule, 38 | AtRule, 39 | responsivePrefixClassSeparator, 40 | result, 41 | }); 42 | }, 43 | }, 44 | }; 45 | }; 46 | 47 | /** @typedef {import('postcss').Rule} Rule */ 48 | /** @typedef {import('postcss').Declaration} Declaration */ 49 | 50 | /** 51 | * Generates PostCSS Rule 52 | * @param {ClassObject} classObject Class object 53 | * @param {Object} obj 54 | * @return {Rule} 55 | */ 56 | function generateUtilityClassRules( 57 | classObject, 58 | { Rule, Declaration, selector = ({ selectorBase }) => `.${selectorBase}` }, 59 | ) { 60 | const { selectorBase, prop, value } = classObject; 61 | 62 | const properties = Array.isArray(prop) ? prop : [prop]; 63 | 64 | const rule = new Rule({ selector: selector(classObject) }); 65 | properties.forEach((prop) => { 66 | rule.append( 67 | new Declaration({ prop, value: /** @type {string} */ (value) }), 68 | ); 69 | }); 70 | return rule; 71 | } 72 | 73 | function insertUtilityClasses( 74 | atRule, 75 | classObjects, 76 | viewportEntries, 77 | { AtRule, Rule, Declaration, responsivePrefixClassSeparator, result }, 78 | ) { 79 | // Insert utility classes 80 | if ( 81 | atRule.params?.includes("(utility-classes)") && 82 | atRule.parent?.type === "root" 83 | ) { 84 | if (!classObjects.length) { 85 | result.warn( 86 | "No utility classes generated. Looks like `utilityClasses` option is missing or invalid.", 87 | { node: atRule }, 88 | ); 89 | return; 90 | } 91 | 92 | const rules = classObjects.flatMap((c) => 93 | generateUtilityClassRules(c, { 94 | Rule, 95 | Declaration, 96 | selector: ({ selectorBase }) => `.${selectorBase}`, 97 | }), 98 | ); 99 | 100 | const mediaAtRules = viewportEntries.map(([mqPrefix, mqParams]) => { 101 | const mq = new AtRule({ 102 | name: "media", 103 | params: `(min-width: ${mqParams})`, 104 | }); 105 | const rules = classObjects 106 | .flatMap((c) => { 107 | if (c.skipViewportVariant) { 108 | return; 109 | } 110 | 111 | const rule = generateUtilityClassRules(c, { 112 | Rule, 113 | Declaration, 114 | selector: ({ selectorBase }) => 115 | `.${mqPrefix}${responsivePrefixClassSeparator}${selectorBase}`, 116 | }); 117 | 118 | return rule; 119 | }) 120 | .filter((r) => r !== undefined); 121 | 122 | // @ts-ignore 123 | mq.append(rules); 124 | 125 | return mq; 126 | }); 127 | 128 | atRule.replaceWith([...rules, ...mediaAtRules]); 129 | } 130 | } 131 | 132 | function insertCustomProperties(atRule, tokenObjects, { Declaration }) { 133 | // Insert custom properties 134 | if ( 135 | atRule.params?.includes("(custom-properties") && 136 | atRule.parent?.type !== "root" 137 | ) { 138 | const groups = parseAtRuleParams(atRule.params); 139 | 140 | const FT = 141 | groups === undefined // When group is not specified only list ungrouped ones 142 | ? tokenObjects.filter((t) => t.group === undefined) 143 | : groups.includes("all") // List all when 'all' is mentioned 144 | ? tokenObjects 145 | : tokenObjects 146 | .filter((t) => t.group !== undefined) 147 | // @ts-ignore 148 | .filter((t) => groups.includes(t.group)); 149 | 150 | const csscustomProperties = FT.map( 151 | ({ prop, value }) => 152 | new Declaration({ prop, value: /** @type {string} */ (value) }), 153 | ); 154 | atRule.replaceWith(csscustomProperties); 155 | } 156 | } 157 | 158 | /** 159 | * @param {object|string|string[]} v 160 | * @return {string|number|null} 161 | */ 162 | function processValue(v) { 163 | if (Array.isArray(v)) { 164 | return v.join(", "); 165 | } 166 | if (["string", "number"].includes(typeof v)) { 167 | return v; 168 | } 169 | return null; 170 | } 171 | 172 | /** 173 | * @typedef {object} TokenObject 174 | * @property {string} prop 175 | * @property {string} name 176 | * @property {string|number} value 177 | * @property {string=} id 178 | * @property {string=} group 179 | */ 180 | 181 | function createCustomProperties( 182 | tokens, 183 | { prefixMap = new Map(), groupMap = new Map(), separator = "-" } = {}, 184 | ) { 185 | /** 186 | * @param {object} object The object 187 | * @param {string=} parentId The prefix 188 | * @return {Array} 189 | */ 190 | function createTokenObject(object, parentId) { 191 | return Object.keys(object).reduce( 192 | /** @param {Array} acc */ 193 | (acc, k) => { 194 | const prefix = 195 | parentId === undefined 196 | ? undefined 197 | : prefixMap.get(parentId) !== undefined 198 | ? prefixMap.get(parentId) 199 | : parentId; 200 | 201 | const prop = prefix === undefined ? k : `${prefix}.${k}`; 202 | 203 | const value = processValue(object[k]); 204 | const group = groupMap.get(parentId); 205 | 206 | if (value) { 207 | return [ 208 | ...acc, 209 | { 210 | name: k, 211 | prop, 212 | value, 213 | parentId, 214 | group, 215 | }, 216 | ]; 217 | } 218 | return [...acc, ...createTokenObject(object[k], prop)]; 219 | }, 220 | [], 221 | ); 222 | } 223 | 224 | return createTokenObject(tokens).map((d) => { 225 | const propBaseName = kebabCase(d.prop); 226 | const className = propBaseName; 227 | return { 228 | ...d, 229 | prop: `--${propBaseName}`, 230 | }; 231 | }); 232 | } 233 | 234 | /** 235 | * @typedef {object} ClassObject 236 | * @property {string} selectorBase 237 | * @property {string} prop 238 | * @property {string|number} value 239 | * @property {boolean} skipViewportVariant 240 | */ 241 | 242 | /** 243 | * Creates utility classes. 244 | * 245 | * @param {Array} tokenObjects 246 | * @param {object} options 247 | * @return {Array} 248 | */ 249 | function createUtilityClasses(tokenObjects, options) { 250 | return options.flatMap( 251 | ({ id, property, prefix, responsiveVariants = false }) => { 252 | return tokenObjects 253 | .filter((t) => t.parentId === id) 254 | .map(({ prop, value, name }) => { 255 | const selectorPrefix = prefix ?? id; 256 | const selectorBase = kebabCase(`${selectorPrefix} ${name}`); 257 | return { 258 | selectorBase, 259 | prop: property, 260 | value: `var(${prop})`, 261 | skipViewportVariant: !responsiveVariants, 262 | }; 263 | }); 264 | }, 265 | ); 266 | } 267 | 268 | module.exports.postcss = true; 269 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # postcss-design-token-utils 2 | 3 | PostCSS plugin to convert design tokens to CSS custom properties and utility classes. 4 | 5 | [**Try it out in the Playground!**](https://tokens2css.nanools.com) 6 | 7 | This plugin is inspired by Andy Bell's [Gorko](https://github.com/Andy-set-studio/gorko), and his latest [utility class generation project](https://github.com/Set-Creative-Studio/cube-boilerplate/) using Tailwind. 8 | The method of using Tailwind comes with fancy features like just-in-time class generation. 9 | But, I would rather not depend on Tailwind for one feature of generating classes and utility class. 10 | Not only that, I find the Tailwind configuration (like Andy did) quite scary. 11 | Who knows if the same configuration will work in the next version? 12 | 13 | My motivation is to create a plugin that does one job (actually two) of generating CSS custom properties and utility classes from design tokens. 14 | You can maintain the tokens in which ever formats – JSON, or YAML – you prefer. 15 | Do turn into an object, before passing the tokens to this plugin. 16 | 17 | You can pair this plugin with [purgecss](https://purgecss.com) to remove unused CSS class rules. 18 | 19 | See [my example project](https://github.com/saneef/postcss-design-token-utils-sample-project) to see how I convert JSON files to JS Object and use purgecss. 20 | 21 | ## Usage 22 | 23 | ### Installation 24 | 25 | ```sh 26 | npm install --save-dev postcss-design-token-utils 27 | ``` 28 | 29 | ### Configuration 30 | 31 | ```js 32 | // postcss.config.js 33 | const postcssDesignTokenUtils = require("postcss-design-token-utils"); 34 | 35 | const tokens = { 36 | color: { 37 | accent: "#16a34a", 38 | dark: "#111827", 39 | light: "#f3f4f6", 40 | }, 41 | space: { 42 | xs: "0.25rem", 43 | s: "0.5rem", 44 | m: "1rem", 45 | l: "2rem", 46 | }, 47 | }; 48 | 49 | const config = { 50 | plugins: [ 51 | /* ...Other plugins... */ 52 | 53 | postcssDesignTokenUtils({ 54 | tokens, 55 | /* Plugin options */ 56 | }), 57 | ], 58 | }; 59 | ``` 60 | 61 | This plug exposes `@design-token-utils` at-rule. 62 | Using the at-rule with suitable params you can generate CSS custom properties and utility classes. 63 | 64 | ### CSS properties from design tokens 65 | 66 | At-rule, `@design-token-utils (custom-properties);` is used to generated CSS custom properties. 67 | The at-rule should be used within a rule (selector), else the properties won't be generated. 68 | 69 | ```css 70 | /* source.css */ 71 | :root { 72 | @design-token-utils (custom-properties); 73 | } 74 | ``` 75 | 76 | For the `token` passed in the example config, these custom properties will be populated. 77 | 78 | ```css 79 | /* output.css */ 80 | :root { 81 | --color-accent: #16a34a; 82 | --color-dark: #111827; 83 | --color-light: #f3f4f6; 84 | --space-xs: 0.25rem; 85 | --space-s: 0.5rem; 86 | --space-m: 1rem; 87 | --space-l: 2rem; 88 | } 89 | ``` 90 | 91 | #### Grouping tokens 92 | 93 | Using options, `customProperties`, the behaviour of custom property generation can be altered. 94 | 95 | In the below example, we have two sets of tokens, `color` and `darkThemeColor` (these are `id`s). 96 | 97 | Using the options, the `darkThemeColor` is marked with the group name `dark`. 98 | You can also see that the `prefix` is set to `color`, which will change the prefix of the generated properties. 99 | By default, `id` is used as prefix. 100 | 101 | ```js 102 | // postcss.config.js 103 | const tokens = { 104 | color: { 105 | accent: "#16a34a", 106 | dark: "#111827", 107 | light: "#f3f4f6", 108 | }, 109 | darkThemeColor: { 110 | accent: "#16a34a", 111 | // The `light` and `dark` values are interchanged 112 | light: "#111827", 113 | dark: "#f3f4f6", 114 | }, 115 | }; 116 | 117 | const config = { 118 | plugins: [ 119 | postcssDesignTokenUtils({ 120 | tokens, 121 | customProperties: [ 122 | { id: "darkThemeColor", prefix: "color", group: "dark" }, 123 | ], 124 | }), 125 | ], 126 | }; 127 | ``` 128 | 129 | The call `@design-token-utils (custom-properties);` only generates tokens without any groups. We pass the group names (comma-separated) with the at-rule call. Example: `@design-token-utils (custom-properties: dark);` 130 | 131 | In cases where you need to generate all the custom properties, grouped and ungrouped, you can pass `all` as an argument, like `@design-token-utils (custom-properties: all);` 132 | 133 | Inside the `(prefers-color-scheme: dark)` media query, we can call ` @design-token-utils (custom-properties: dark);` to generate `dark` group properties. 134 | 135 | ```css 136 | /* source.css */ 137 | :root { 138 | @design-token-utils (custom-properties); 139 | } 140 | 141 | @media (prefers-color-scheme: dark) { 142 | :root { 143 | @design-token-utils (custom-properties: dark); 144 | } 145 | } 146 | ``` 147 | 148 | With the above code, we have reassigned the properties from `:root` with a new set of values within `(prefers-color-scheme: dark)` media query. 149 | 150 | ```css 151 | /* output.css */ 152 | :root { 153 | --color-accent: #16a34a; 154 | --color-dark: #111827; 155 | --color-light: #f3f4f6; 156 | } 157 | 158 | @media (prefers-color-scheme: dark) { 159 | :root { 160 | --color-accent: #16a34a; 161 | --color-light: #111827; 162 | --color-dark: #f3f4f6; 163 | } 164 | } 165 | ``` 166 | 167 | Similarly, you can invoke at-rule within a class selector (`.dark {}`) or a data attribute (`[data-theme="dark"] {}`). 168 | This is handy for generating scheme or theme base properties. 169 | 170 | ### Utility classes 171 | 172 | > [!IMPORTANT] 173 | > The utility classes are based on CSS custom properties from design tokens. 174 | > Make sure custom properties are included. 175 | 176 | The generation of **utility classes is opt-in** based on token `id`. 177 | Using the `utilityClasses` option, we need to provide an array of object with `id`, `property`, and optionally `prefix` for class generation. 178 | Then, we can use at-rule `@design-token-utils (utility-classes);` to insert in the stylesheet. 179 | 180 | ```js 181 | // postcss.config.js 182 | 183 | const tokens = { 184 | color: { 185 | accent: "#16a34a", 186 | dark: "#111827", 187 | light: "#f3f4f6", 188 | }, 189 | }; 190 | 191 | const config = { 192 | plugins: [ 193 | postcssDesignTokenUtils({ 194 | tokens, 195 | utilityClasses: [ 196 | { 197 | id: "color", 198 | property: "background-color", 199 | prefix: "bg", 200 | }, 201 | ], 202 | }), 203 | ], 204 | }; 205 | ``` 206 | 207 | Using the `utilityClasses` option, we pick one set of token using `id: "color"`. 208 | The CSS `property` is set to `background-color` and the class name `prefix` to `bg`. 209 | If we don't set `prefix` is not set, the `id` will be used as a prefix, which may be useless for most of the cases. 210 | 211 | Then, we can use at-rule `@design-token-utils (utility-classes);` to insert the classes in the stylesheet. 212 | 213 | ```css 214 | /* source.css */ 215 | :root { 216 | @design-token-utils (custom-properties); 217 | } 218 | 219 | @design-token-utils (utility-classes); 220 | ``` 221 | 222 | Once built, this is the output CSS file. 223 | 224 | ```css 225 | /* output.css */ 226 | :root { 227 | --color-accent: #16a34a; 228 | --color-dark: #111827; 229 | --color-light: #f3f4f6; 230 | } 231 | .bg-accent { 232 | background-color: var(--color-accent); 233 | } 234 | .bg-dark { 235 | background-color: var(--color-dark); 236 | } 237 | .bg-light { 238 | background-color: var(--color-light); 239 | } 240 | ``` 241 | 242 | The `property` also supports an array of property values, like `["margin-top", "margin-bottom"]`, to be included within a utility class. 243 | 244 | #### Responsive class variants 245 | 246 | It is possible to generate responsive modifier class names. 247 | We need to provide breakpoints and specify which classes need responsive variants. 248 | 249 | ```js 250 | // postcss.config.js 251 | 252 | // tokens object from previous example 253 | 254 | const config = { 255 | plugins: [ 256 | postcssDesignTokenUtils({ 257 | tokens, 258 | breakpoints: { 259 | sm: "320px", // 👈 Added break points 260 | md: "640px", 261 | }, 262 | utilityClasses: [ 263 | { 264 | id: "color", 265 | property: "background-color", 266 | prefix: "bg", 267 | responsiveVariants: true, // 👈 Sets `responsiveVariants` to `true` 268 | }, 269 | ], 270 | }), 271 | ], 272 | }; 273 | ``` 274 | 275 | In the previous example, if we provide breakpoints and set `responsiveVariants: true` for token ID, we get below output. 276 | 277 | ```css 278 | /* output.css */ 279 | :root { 280 | --color-accent: #16a34a; 281 | --color-dark: #111827; 282 | --color-light: #f3f4f6; 283 | } 284 | 285 | .bg-accent { 286 | background-color: var(--color-accent); 287 | } 288 | .bg-dark { 289 | background-color: var(--color-dark); 290 | } 291 | .bg-light { 292 | background-color: var(--color-light); 293 | } 294 | @media (min-width: 320px) { 295 | .sm-bg-accent { 296 | background-color: var(--color-accent); 297 | } 298 | .sm-bg-dark { 299 | background-color: var(--color-dark); 300 | } 301 | .sm-bg-light { 302 | background-color: var(--color-light); 303 | } 304 | } 305 | @media (min-width: 640px) { 306 | .md-bg-accent { 307 | background-color: var(--color-accent); 308 | } 309 | .md-bg-dark { 310 | background-color: var(--color-dark); 311 | } 312 | .md-bg-light { 313 | background-color: var(--color-light); 314 | } 315 | } 316 | ``` 317 | 318 | You can use `responsivePrefixClassSeparator` property (default: `-`) in `options` to change the separator between responsive prefix and class name. 319 | 320 | You can generate Tailwind style responsive utility classes with `responsivePrefixClassSeparator: "\\:"`. 321 | 322 | > [!WARNING] 323 | > Beware if you are using purgecss. 324 | > Class names with some special character are not considered, by default. 325 | > [See note](https://purgecss.com/extractors.html#default-extractor) in purgecss documentation. 326 | 327 | ## Nested tokens 328 | 329 | Even if your token object is nested, this plugin can generate CSS custom properties and class names. 330 | The ID will be generated by merging the IDs of parents and children (recursively), separated by a period. 331 | Example: The tokens within `gray` can be targeted using `color.gray`. 332 | 333 | ```js 334 | // postcss.config.css 335 | const token = { 336 | color: { 337 | gray: { 100: "#f1f5f9", 800: "#1e293b" }, 338 | primary: { 100: "#dcfce7", 800: "#166534" }, 339 | }, 340 | }; 341 | 342 | const config = { 343 | plugins: [ 344 | postcssDesignTokenUtils({ 345 | tokens, 346 | utilityClasses: [ 347 | { 348 | id: "color.gray", 349 | property: "background-color", 350 | prefix: "bg-shade", 351 | }, 352 | ], 353 | }), 354 | ], 355 | }; 356 | ``` --------------------------------------------------------------------------------