├── .editorconfig ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── index.test.js ├── package-lock.json └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage/ 3 | node_modules/ 4 | npm-debug.log 5 | dist 6 | test 7 | 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | npm-debug.log 3 | package-lock.json 4 | yarn.lock 5 | 6 | *.test.js 7 | .travis.yml 8 | .github 9 | .editorconfig 10 | coverage/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## v1.0.0 6 | Initial release. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2023 strarsis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostCSS Utopia 2 | 3 | Generate fluid typopgraphy and space scales in PostCSS. Uses [utopia-core](https://github.com/trys/utopia-core) calculations under the hood. 4 | 5 | ### Configuration 6 | 7 | Supplying `minWidth` and `maxWidth` when instantiating the plugin sets the default min/max viewports for all the methods below. 8 | 9 | ```js 10 | // postcss.config.js 11 | 12 | module.exports = { 13 | plugins: [ 14 | require('postcss-utopia')({ 15 | minWidth?: number; // Default minimum viewport 16 | maxWidth?: number; // Default maximum viewport 17 | }) 18 | ] 19 | } 20 | ``` 21 | 22 | ## Declaration methods 23 | 24 | Declaration methods output CSS values (in this case, a `clamp`), and follow the format of `utopia.METHOD_NAME()`. 25 | 26 | ### `utopia.clamp` 27 | 28 | ```css 29 | h1 { 30 | padding: utopia.clamp(16, 40); /* Uses the plugin viewport defaults */ 31 | } 32 | 33 | p { 34 | margin: utopia.clamp(16, 40, 320, 1440); /* Uses supplied viewports */ 35 | } 36 | ``` 37 | 38 | ## AtRule methods 39 | 40 | AtRule methods generate multiple lines of CSS (in the form of custom properties). They follow the format of `@utopia METHOD_NAME({})`. 41 | 42 | ### `@utopia typeScale` 43 | 44 | Pass in any [utopia-core](https://github.com/trys/utopia-core) configuration and generate a set of fluid custom properties. 45 | 46 | ```css 47 | :root { 48 | @utopia typeScale({ 49 | minWidth: 320, /* Defaults to plugin minWidth */ 50 | maxWidth: 1240, /* Defaults to plugin maxWidth */ 51 | minFontSize: 16, 52 | maxFontSize: 18, 53 | minTypeScale: 1.2, 54 | maxTypeScale: 1.25, 55 | positiveSteps: 5, 56 | negativeSteps: 2, 57 | relativeTo: 'viewport', /* Optional */ 58 | prefix: 'step' /* Optional */ 59 | }); 60 | 61 | /* Generates 62 | --step--2: clamp(...); 63 | --step--1: clamp(...); etc. 64 | */ 65 | } 66 | ``` 67 | 68 | ### `@utopia spaceScale` 69 | 70 | Pass in any [utopia-core](https://github.com/trys/utopia-core) configuration and generate a set of fluid custom properties. 71 | 72 | ```css 73 | :root { 74 | @utopia spaceScale({ 75 | minWidth: 320, /* Defaults to plugin minWidth */ 76 | maxWidth: 1240, /* Defaults to plugin maxWidth */ 77 | minSize: 16, 78 | maxSize: 18, 79 | positiveSteps: [1.5, 2, 3], 80 | negativeSteps: [0.75, 0.5], 81 | customSizes: ['s-l'], 82 | relativeTo: 'viewport', /* Optional */ 83 | prefix: 'space', /* Optional */ 84 | usePx: false, /* Optional */ 85 | }); 86 | 87 | /* Generates 88 | --space-2xs: clamp(...); 89 | --space-xs: clamp(...); etc. 90 | 91 | --space-2xs-xs: clamp(...); etc. 92 | 93 | --space-s-l: clamp(...); etc. 94 | */ 95 | } 96 | ``` 97 | 98 | ### `@utopia clamps` 99 | 100 | Pass in any [utopia-core](https://github.com/trys/utopia-core) configuration and generate a set of fluid custom properties. 101 | 102 | ```css 103 | :root { 104 | @utopia clamps({ 105 | minWidth: 320, /* Defaults to plugin minWidth */ 106 | maxWidth: 1240, /* Defaults to plugin minWidth */ 107 | pairs: [ 108 | [16, 40] 109 | ], 110 | usePx: false, /* Optional */ 111 | prefix: 'space', /* Optional */ 112 | relativeTo: 'viewport' /* Optional */ 113 | }); 114 | } 115 | ``` -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'utopia' { 2 | import { PluginCreator } from 'postcss'; 3 | 4 | interface UtopiaOptions { 5 | /** 6 | * Default minimum viewport 7 | */ 8 | minWidth?: number; 9 | 10 | /** 11 | * Default maximum viewport 12 | */ 13 | maxWidth?: number; 14 | } 15 | 16 | const utopia: PluginCreator; 17 | 18 | export default utopia; 19 | } 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const CSSValueParser = require('postcss-value-parser') 2 | const { calculateClamp, calculateClamps, calculateSpaceScale, calculateTypeScale } = require('utopia-core'); 3 | 4 | /** 5 | * @type {import('postcss').PluginCreator} 6 | */ 7 | module.exports = (opts) => { 8 | 9 | const DEFAULTS = { minWidth: 320, maxWidth: 1240, minFontSize: 16, maxFontSize: 20 } 10 | const config = Object.assign(DEFAULTS, opts) 11 | 12 | const typeScale = (atRule, result) => { 13 | const { nodes } = CSSValueParser(atRule.params); 14 | const params = nodes[0].nodes.filter(x => ['word', 'string'].includes(x.type) && x.value !== '{' && x.value !== '}'); 15 | 16 | const typeParams = { 17 | minWidth: config.minWidth, 18 | maxWidth: config.maxWidth, 19 | minFontSize: 16, 20 | maxFontSize: 16, 21 | minTypeScale: 1.1, 22 | maxTypeScale: 1.1, 23 | positiveSteps: 0, 24 | negativeSteps: 0, 25 | relativeTo: 'viewport', 26 | prefix: 'step', 27 | }; 28 | const paramKeys = Object.keys(typeParams); 29 | 30 | if (!params.length) { 31 | atRule.remove(); 32 | return false; 33 | } 34 | 35 | for (let index = 0; index < params.length; index = index + 2) { 36 | const element = params[index]; 37 | const key = element.value; 38 | const value = params[index + 1]; 39 | if (!key || value === undefined) continue; 40 | 41 | if (paramKeys.includes(key)) { 42 | typeParams[key] = isNaN(typeParams[key]) ? value.value : Number(value.value); 43 | } 44 | } 45 | 46 | const typeScale = calculateTypeScale(typeParams); 47 | const response = `${typeScale.map(step => { 48 | return `--${typeParams.prefix || 'step'}-${step.step}: ${step.clamp};` 49 | }).join('\n')}` 50 | 51 | typeScale.some(step => { 52 | if (step.wcagViolation) { 53 | atRule.warn( 54 | result, 55 | `WCAG SC 1.4.4 violation for viewports ${step.wcagViolation.from}px to ${step.wcagViolation.to}px.` 56 | ); 57 | return true; 58 | } 59 | return false; 60 | }); 61 | 62 | atRule.replaceWith(response); 63 | 64 | return false; 65 | } 66 | 67 | const spaceScale = (atRule) => { 68 | const { nodes } = CSSValueParser(atRule.params); 69 | const params = nodes[0].nodes.filter(x => ['word', 'string'].includes(x.type) && x.value !== '{' && x.value !== '}'); 70 | 71 | if (!params.length) { 72 | atRule.remove(); 73 | return false; 74 | } 75 | 76 | const spaceParams = { 77 | minWidth: config.minWidth, 78 | maxWidth: config.maxWidth, 79 | minSize: 16, 80 | maxSize: 16, 81 | positiveSteps: [], 82 | negativeSteps: [], 83 | customSizes: [], 84 | relativeTo: 'viewport', 85 | usePx: false, 86 | prefix: 'space' 87 | }; 88 | const paramKeys = Object.keys(spaceParams); 89 | const arrayParams = ['positiveSteps', 'negativeSteps', 'customSizes']; 90 | const keyParams = paramKeys.filter(x => !arrayParams.includes(x)); 91 | 92 | keyParams.forEach(param => { 93 | const index = params.findIndex(x => x.value === param); 94 | if (index !== -1 && params[index + 1] !== undefined) { 95 | if (['minWidth', 'maxWidth', 'minSize', 'maxSize'].includes(param)) { 96 | spaceParams[param] = Number(params[index + 1].value); 97 | } else if ('usePx' === param) { 98 | spaceParams[param] = params[index + 1].value === 'true'; 99 | } else { 100 | spaceParams[param] = params[index + 1].value; 101 | } 102 | 103 | params.splice(index, 2); 104 | } 105 | }); 106 | 107 | const remainingParams = params.map(x => x.value.replace('[', '').replace(']', '')).filter(x => x !== ''); 108 | let runningKey = ''; 109 | remainingParams.forEach(val => { 110 | if (arrayParams.includes(val)) { 111 | runningKey = val; 112 | } else { 113 | spaceParams[runningKey].push(runningKey === 'customSizes' ? val : Number(val)); 114 | } 115 | }); 116 | 117 | const spaceScale = calculateSpaceScale(spaceParams); 118 | 119 | const response = `${[...spaceScale.sizes, ...spaceScale.oneUpPairs, ...spaceScale.customPairs].map(step => { 120 | return `--${spaceParams.prefix || 'space'}-${step.label}: ${spaceParams.usePx ? step.clampPx : step.clamp};` 121 | }).join('\n')}` 122 | 123 | atRule.replaceWith(response); 124 | 125 | return false; 126 | } 127 | 128 | const clamps = (atRule) => { 129 | const { nodes } = CSSValueParser(atRule.params); 130 | const params = nodes[0].nodes.filter(x => ['word', 'string'].includes(x.type) && x.value !== '{' && x.value !== '}'); 131 | 132 | if (!params.length) { 133 | atRule.remove(); 134 | return false; 135 | } 136 | 137 | const clampsParams = { 138 | minWidth: config.minWidth, 139 | maxWidth: config.maxWidth, 140 | pairs: [], 141 | relativeTo: 'viewport', 142 | prefix: 'space', 143 | usePx: false 144 | }; 145 | const paramKeys = Object.keys(clampsParams); 146 | const arrayParams = ['pairs']; 147 | const keyParams = paramKeys.filter(x => !arrayParams.includes(x)); 148 | 149 | keyParams.forEach(param => { 150 | const index = params.findIndex(x => x.value === param); 151 | if (index !== -1 && params[index + 1] !== undefined) { 152 | if (['minWidth', 'maxWidth'].includes(param)) { 153 | clampsParams[param] = Number(params[index + 1].value); 154 | } else if ('usePx' === param) { 155 | clampsParams[param] = params[index + 1].value === 'true'; 156 | } else { 157 | clampsParams[param] = params[index + 1].value; 158 | } 159 | 160 | params.splice(index, 2); 161 | } 162 | }); 163 | 164 | const remainingParams = params.map(x => x.value.replaceAll('[', '').replaceAll(']', '')).filter(x => x !== ''); 165 | let runningKey = ''; 166 | remainingParams.forEach(val => { 167 | if (arrayParams.includes(val)) { 168 | runningKey = val; 169 | } else { 170 | clampsParams[runningKey].push(Number(val)); 171 | } 172 | }); 173 | 174 | clampsParams.pairs = clampsParams.pairs.reduce(function (pairs, value, index, array) { 175 | if (index % 2 === 0) 176 | pairs.push(array.slice(index, index + 2)); 177 | return pairs; 178 | }, []); 179 | 180 | const clampScale = calculateClamps(clampsParams); 181 | const response = `${clampScale.map(step => { 182 | return `--${clampsParams.prefix || 'space'}-${step.label}: ${clampsParams.usePx ? step.clampPx : step.clamp};` 183 | }).join('\n')}`; 184 | 185 | atRule.replaceWith(response); 186 | 187 | return false; 188 | } 189 | 190 | return { 191 | postcssPlugin: 'utopia', 192 | 193 | AtRule: { 194 | utopia: (atRule, { result }) => { 195 | if (atRule.params.startsWith('typeScale(')) { 196 | return typeScale(atRule, result); 197 | } 198 | 199 | if (atRule.params.startsWith('spaceScale(')) { 200 | return spaceScale(atRule); 201 | } 202 | 203 | if (atRule.params.startsWith('clamps(')) { 204 | return clamps(atRule); 205 | } 206 | } 207 | }, 208 | 209 | Declaration(decl) { 210 | // The faster way to find Declaration node 211 | const parsedValue = CSSValueParser(decl.value) 212 | 213 | let valueChanged = false 214 | parsedValue.walk(node => { 215 | if (node.type !== 'function' || node.value !== 'utopia.clamp') { 216 | return 217 | } 218 | 219 | let [minSize, maxSize, minWidth, maxWidth] = node.nodes.filter(x => x.type === 'word').map(x => x.value).map(Number); 220 | if (!minWidth) minWidth = config.minWidth; 221 | if (!maxWidth) maxWidth = config.maxWidth; 222 | 223 | if (!minSize || !maxSize || !minWidth || !maxWidth) return false; 224 | 225 | // Generate clamp 226 | const clamp = calculateClamp({ minSize, maxSize, minWidth, maxWidth }); 227 | 228 | // Convert back PostCSS nodes 229 | const { nodes: [{ nodes }] } = CSSValueParser(clamp); 230 | 231 | node.value = 'clamp'; 232 | node.nodes = nodes; 233 | valueChanged = true 234 | 235 | return false 236 | }) 237 | 238 | if (valueChanged) { 239 | decl.value = CSSValueParser.stringify(parsedValue) 240 | } 241 | 242 | } 243 | } 244 | } 245 | 246 | module.exports.postcss = true 247 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss') 2 | 3 | const plugin = require('./') 4 | 5 | async function run (input, output, opts = { }, expectedWarnings = 0) { 6 | let result = await postcss([plugin(opts)]).process(input, { from: undefined }) 7 | expect(result.css).toEqual(output) 8 | expect(result.warnings()).toHaveLength(expectedWarnings) 9 | } 10 | 11 | // Type 12 | 13 | it('generates a type scale using config values', async () => { 14 | await run( 15 | `:root { @utopia typeScale({ 16 | minFontSize: 16, 17 | maxFontSize: 18, 18 | minTypeScale: 1.2, 19 | maxTypeScale: 1.25, 20 | positiveSteps: 5, 21 | negativeSteps: 2 22 | }); }`, 23 | `:root {--step-5: clamp(2.4883rem, 2.1597rem + 1.6433vi, 3.4332rem);--step-4: clamp(2.0736rem, 1.8395rem + 1.1704vi, 2.7466rem);--step-3: clamp(1.728rem, 1.5648rem + 0.8161vi, 2.1973rem);--step-2: clamp(1.44rem, 1.3295rem + 0.5527vi, 1.7578rem);--step-1: clamp(1.2rem, 1.1283rem + 0.3587vi, 1.4063rem);--step-0: clamp(1rem, 0.9565rem + 0.2174vi, 1.125rem);--step--1: clamp(0.8333rem, 0.8101rem + 0.1159vi, 0.9rem);--step--2: clamp(0.6944rem, 0.6856rem + 0.0444vi, 0.72rem); }`, 24 | { minWidth: 320, maxWidth: 1240 } 25 | ) 26 | }) 27 | 28 | it('generates a type scale using supplied values', async () => { 29 | await run( 30 | `:root { @utopia typeScale({ 31 | minWidth: 320, 32 | maxWidth: 1240, 33 | minFontSize: 16, 34 | maxFontSize: 18, 35 | minTypeScale: 1.2, 36 | maxTypeScale: 1.25, 37 | positiveSteps: 5, 38 | negativeSteps: 2, 39 | relativeTo: 'viewport' 40 | }); }`, 41 | `:root {--step-5: clamp(2.4883rem, 2.1597rem + 1.6433vi, 3.4332rem);--step-4: clamp(2.0736rem, 1.8395rem + 1.1704vi, 2.7466rem);--step-3: clamp(1.728rem, 1.5648rem + 0.8161vi, 2.1973rem);--step-2: clamp(1.44rem, 1.3295rem + 0.5527vi, 1.7578rem);--step-1: clamp(1.2rem, 1.1283rem + 0.3587vi, 1.4063rem);--step-0: clamp(1rem, 0.9565rem + 0.2174vi, 1.125rem);--step--1: clamp(0.8333rem, 0.8101rem + 0.1159vi, 0.9rem);--step--2: clamp(0.6944rem, 0.6856rem + 0.0444vi, 0.72rem); }`, 42 | {} 43 | ) 44 | }) 45 | 46 | it('generates a container query type scale using supplied values', async () => { 47 | await run( 48 | `:root { @utopia typeScale({ 49 | minWidth: 320, 50 | maxWidth: 1240, 51 | minFontSize: 16, 52 | maxFontSize: 18, 53 | minTypeScale: 1.2, 54 | maxTypeScale: 1.25, 55 | positiveSteps: 5, 56 | negativeSteps: 2, 57 | relativeTo: 'container' 58 | }); }`, 59 | `:root {--step-5: clamp(2.4883rem, 2.1597rem + 1.6433cqi, 3.4332rem);--step-4: clamp(2.0736rem, 1.8395rem + 1.1704cqi, 2.7466rem);--step-3: clamp(1.728rem, 1.5648rem + 0.8161cqi, 2.1973rem);--step-2: clamp(1.44rem, 1.3295rem + 0.5527cqi, 1.7578rem);--step-1: clamp(1.2rem, 1.1283rem + 0.3587cqi, 1.4063rem);--step-0: clamp(1rem, 0.9565rem + 0.2174cqi, 1.125rem);--step--1: clamp(0.8333rem, 0.8101rem + 0.1159cqi, 0.9rem);--step--2: clamp(0.6944rem, 0.6856rem + 0.0444cqi, 0.72rem); }`, 60 | {} 61 | ) 62 | }) 63 | 64 | it('generates a type scale with no negative steps', async () => { 65 | await run( 66 | `:root { @utopia typeScale({ 67 | minWidth: 320, 68 | maxWidth: 1240, 69 | minFontSize: 16, 70 | maxFontSize: 18, 71 | minTypeScale: 1.2, 72 | maxTypeScale: 1.25, 73 | positiveSteps: 5, 74 | negativeSteps: 0, 75 | }); }`, 76 | `:root {--step-5: clamp(2.4883rem, 2.1597rem + 1.6433vi, 3.4332rem);--step-4: clamp(2.0736rem, 1.8395rem + 1.1704vi, 2.7466rem);--step-3: clamp(1.728rem, 1.5648rem + 0.8161vi, 2.1973rem);--step-2: clamp(1.44rem, 1.3295rem + 0.5527vi, 1.7578rem);--step-1: clamp(1.2rem, 1.1283rem + 0.3587vi, 1.4063rem);--step-0: clamp(1rem, 0.9565rem + 0.2174vi, 1.125rem); }`, 77 | {} 78 | ) 79 | }) 80 | 81 | it('generates nothing with empty config', async () => { 82 | await run( 83 | `:root { @utopia typeScale(); }`, 84 | `:root { }`, 85 | { minWidth: 320, maxWidth: 1240 } 86 | ) 87 | }) 88 | 89 | it('generates a type scale which violates WCAG SC 1.4.4', async () => { 90 | await run( 91 | `:root { @utopia typeScale({ 92 | minWidth: 320, 93 | maxWidth: 1240, 94 | minFontSize: 16, 95 | maxFontSize: 48, 96 | minTypeScale: 1.2, 97 | maxTypeScale: 1.25, 98 | positiveSteps: 5, 99 | negativeSteps: 2, 100 | }); }`, 101 | `:root {--step-5: clamp(2.4883rem, 0.1694rem + 11.5947vi, 9.1553rem);--step-4: clamp(2.0736rem, 0.2473rem + 9.1315vi, 7.3242rem);--step-3: clamp(1.728rem, 0.291rem + 7.185vi, 5.8594rem);--step-2: clamp(1.44rem, 0.3104rem + 5.6478vi, 4.6875rem);--step-1: clamp(1.2rem, 0.313rem + 4.4348vi, 3.75rem);--step-0: clamp(1rem, 0.3043rem + 3.4783vi, 3rem);--step--1: clamp(0.8333rem, 0.2884rem + 2.7246vi, 2.4rem);--step--2: clamp(0.6944rem, 0.2682rem + 2.1314vi, 1.92rem); }`, 102 | {}, 103 | 1, 104 | ) 105 | }) 106 | 107 | // Space 108 | 109 | it('generates a space scale using config values', async () => { 110 | await run( 111 | `:root { 112 | @utopia spaceScale({ 113 | minSize: 16, 114 | maxSize: 18, 115 | positiveSteps: [1.5, 2, 3, 4, 5], 116 | negativeSteps: [0.75, 0.5, 0.25], 117 | customSizes: ['s-l', '3xl-s'], 118 | }); }`, 119 | `:root {--space-3xs: clamp(0.25rem, 0.2283rem + 0.1087vi, 0.3125rem);--space-2xs: clamp(0.5rem, 0.4783rem + 0.1087vi, 0.5625rem);--space-xs: clamp(0.75rem, 0.7065rem + 0.2174vi, 0.875rem);--space-s: clamp(1rem, 0.9565rem + 0.2174vi, 1.125rem);--space-m: clamp(1.5rem, 1.4348rem + 0.3261vi, 1.6875rem);--space-l: clamp(2rem, 1.913rem + 0.4348vi, 2.25rem);--space-xl: clamp(3rem, 2.8696rem + 0.6522vi, 3.375rem);--space-2xl: clamp(4rem, 3.8261rem + 0.8696vi, 4.5rem);--space-3xl: clamp(5rem, 4.7826rem + 1.087vi, 5.625rem);--space-3xs-2xs: clamp(0.25rem, 0.1413rem + 0.5435vi, 0.5625rem);--space-2xs-xs: clamp(0.5rem, 0.3696rem + 0.6522vi, 0.875rem);--space-xs-s: clamp(0.75rem, 0.6196rem + 0.6522vi, 1.125rem);--space-s-m: clamp(1rem, 0.7609rem + 1.1957vi, 1.6875rem);--space-m-l: clamp(1.5rem, 1.2391rem + 1.3043vi, 2.25rem);--space-l-xl: clamp(2rem, 1.5217rem + 2.3913vi, 3.375rem);--space-xl-2xl: clamp(3rem, 2.4783rem + 2.6087vi, 4.5rem);--space-2xl-3xl: clamp(4rem, 3.4348rem + 2.8261vi, 5.625rem);--space-s-l: clamp(1rem, 0.5652rem + 2.1739vi, 2.25rem);--space-3xl-s: clamp(1.125rem, 6.3478rem + -6.7391vi, 5rem); }`, 120 | { minWidth: 320, maxWidth: 1240 } 121 | ) 122 | }) 123 | 124 | it('generates a container query space scale using supplied values', async () => { 125 | await run( 126 | `:root { @utopia spaceScale({ 127 | minWidth: 320, 128 | maxWidth: 1240, 129 | minSize: 16, 130 | maxSize: 18, 131 | positiveSteps: [1.5, 2, 3, 4, 5], 132 | negativeSteps: [0.75, 0.5, 0.25], 133 | customSizes: ['s-l', '3xl-s'], 134 | usePx: 'true', 135 | relativeTo: 'container', 136 | prefix: 'test', 137 | }); }`, 138 | `:root {--test-3xs: clamp(4px, 3.6522px + 0.1087cqi, 5px);--test-2xs: clamp(8px, 7.6522px + 0.1087cqi, 9px);--test-xs: clamp(12px, 11.3043px + 0.2174cqi, 14px);--test-s: clamp(16px, 15.3043px + 0.2174cqi, 18px);--test-m: clamp(24px, 22.9565px + 0.3261cqi, 27px);--test-l: clamp(32px, 30.6087px + 0.4348cqi, 36px);--test-xl: clamp(48px, 45.913px + 0.6522cqi, 54px);--test-2xl: clamp(64px, 61.2174px + 0.8696cqi, 72px);--test-3xl: clamp(80px, 76.5217px + 1.087cqi, 90px);--test-3xs-2xs: clamp(4px, 2.2609px + 0.5435cqi, 9px);--test-2xs-xs: clamp(8px, 5.913px + 0.6522cqi, 14px);--test-xs-s: clamp(12px, 9.913px + 0.6522cqi, 18px);--test-s-m: clamp(16px, 12.1739px + 1.1957cqi, 27px);--test-m-l: clamp(24px, 19.8261px + 1.3043cqi, 36px);--test-l-xl: clamp(32px, 24.3478px + 2.3913cqi, 54px);--test-xl-2xl: clamp(48px, 39.6522px + 2.6087cqi, 72px);--test-2xl-3xl: clamp(64px, 54.9565px + 2.8261cqi, 90px);--test-s-l: clamp(16px, 9.0435px + 2.1739cqi, 36px);--test-3xl-s: clamp(18px, 101.5652px + -6.7391cqi, 80px); }`, 139 | {} 140 | ) 141 | }) 142 | 143 | it('generates nothing with empty config on a space scale', async () => { 144 | await run( 145 | `:root { @utopia spaceScale(); }`, 146 | `:root { }`, 147 | { minWidth: 320, maxWidth: 1240 } 148 | ) 149 | }) 150 | 151 | // Clamps 152 | 153 | it('generates a set of clamps using supplied values', async () => { 154 | await run( 155 | `.test{ @utopia clamps({ 156 | minWidth: 320, 157 | maxWidth: 1240, 158 | pairs: [[16, 40], [48, 20]], 159 | usePx: true, 160 | prefix: 'test', 161 | relativeTo: 'container' 162 | }); }`, 163 | `.test{--test-16-40: clamp(16px, 7.6522px + 2.6087cqi, 40px);--test-48-20: clamp(20px, 57.7391px + -3.0435cqi, 48px); }`, 164 | {} 165 | ) 166 | }) 167 | 168 | it('generates two sets of clamps', async () => { 169 | await run( 170 | `.test{ @utopia clamps({ 171 | minWidth: 320, 172 | maxWidth: 768, 173 | pairs: [ 174 | [16, 24], 175 | ], 176 | prefix: "fluid-sm", 177 | usePx: true, 178 | }); 179 | 180 | @utopia clamps({ 181 | minWidth: 768, 182 | maxWidth: 1440, 183 | pairs: [ 184 | [24, 96], 185 | ], 186 | prefix: "fluid-md", 187 | usePx: true, 188 | }); }`, 189 | `.test{--fluid-sm-16-24: clamp(16px, 10.2857px + 1.7857vi, 24px);--fluid-md-24-96: clamp(24px, -58.2857px + 10.7143vi, 96px); }`, 190 | {} 191 | ) 192 | }) 193 | 194 | it('generates nothing with empty config on a clamp scale', async () => { 195 | await run( 196 | `:root { @utopia clamps(); }`, 197 | `:root { }`, 198 | { minWidth: 320, maxWidth: 1240 } 199 | ) 200 | }) 201 | 202 | // Clamp 203 | 204 | it('generates a clamp using config values', async () => { 205 | await run( 206 | `.test{ background: red; padding: utopia.clamp(16, 40); }`, 207 | `.test{ background: red; padding: clamp(1rem, 0.4783rem + 2.6087vi, 2.5rem); }`, 208 | { minWidth: 320, maxWidth: 1240 } 209 | ) 210 | }) 211 | 212 | it('generates a clamp using supplied values', async () => { 213 | await run( 214 | `.test{ background: red; padding: utopia.clamp(16, 40, 475, 1340); }`, 215 | `.test{ background: red; padding: clamp(1rem, 0.1763rem + 2.7746vi, 2.5rem); }`, 216 | {} 217 | ) 218 | }) 219 | 220 | it('generates a clamp in shorthand form using supplied values', async () => { 221 | await run( 222 | `.test{ background: red; padding: 1rem 0.5rem utopia.clamp(16, 40, 475, 1340); }`, 223 | `.test{ background: red; padding: 1rem 0.5rem clamp(1rem, 0.1763rem + 2.7746vi, 2.5rem); }`, 224 | {} 225 | ) 226 | }) 227 | 228 | it('generates a clamp in shorthand form', async () => { 229 | await run( 230 | `.test{ background: red; padding: 1rem utopia.clamp(16, 40, 475, 1340); }`, 231 | `.test{ background: red; padding: 1rem clamp(1rem, 0.1763rem + 2.7746vi, 2.5rem); }`, 232 | {} 233 | ) 234 | }) 235 | 236 | it('generates two clamps in shorthand form', async () => { 237 | await run( 238 | `.test{ background: red; padding: utopia.clamp(16, 40, 475, 1340) utopia.clamp(16, 40, 475, 1340); }`, 239 | `.test{ background: red; padding: clamp(1rem, 0.1763rem + 2.7746vi, 2.5rem) clamp(1rem, 0.1763rem + 2.7746vi, 2.5rem); }`, 240 | {} 241 | ) 242 | }) 243 | 244 | it('handles values over 100', async () => { 245 | await run( 246 | `.test{ background: red; padding: utopia.clamp(30, 120); }`, 247 | `.test{ background: red; padding: clamp(1.875rem, -0.0815rem + 9.7826vi, 7.5rem); }`, 248 | { minWidth: 320, maxWidth: 1240 } 249 | ) 250 | }) 251 | 252 | it('skips native clamp', async () => { 253 | await run( 254 | `.test{ background: red; margin: clamp(0.7813rem, 0.7747rem + 0.0326vi, 0.8rem);}`, 255 | `.test{ background: red; margin: clamp(0.7813rem, 0.7747rem + 0.0326vi, 0.8rem);}`, 256 | { minWidth: 320, maxWidth: 1240 } 257 | ) 258 | }) 259 | 260 | it('skips declarations without utopia.clamp', async () => { 261 | await run( 262 | `.test{ background: red; }`, 263 | `.test{ background: red; }`, 264 | { minWidth: 320, maxWidth: 1240 } 265 | ) 266 | }) 267 | 268 | it('works with empty declarations', async () => { 269 | await run( 270 | `.test{}`, 271 | `.test{}`, 272 | { minWidth: 320, maxWidth: 1240 } 273 | ) 274 | }) 275 | 276 | it('works with empty input', async () => { 277 | await run( 278 | ``, 279 | ``, 280 | { minWidth: 320, maxWidth: 1240 } 281 | ) 282 | }) 283 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-utopia", 3 | "version": "1.1.0", 4 | "description": "PostCSS plugin to generate fluid typopgraphy and space scales", 5 | "keywords": [ 6 | "postcss", 7 | "css", 8 | "postcss-plugin", 9 | "postcss-utopia", 10 | "utopia" 11 | ], 12 | "scripts": { 13 | "prepublishOnly": "npm run test", 14 | "test": "jest --coverage && eslint .", 15 | "test-css": "postcss -o dist/main.css test/index.css" 16 | }, 17 | "main": "index.js", 18 | "types": "index.d.ts", 19 | "author": "Trys Mudford ", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/trys/postcss-utopia.git" 24 | }, 25 | "peerDependencies": { 26 | "postcss": "^8.4.33" 27 | }, 28 | "dependencies": { 29 | "postcss-value-parser": "^4.2.0", 30 | "utopia-core": "^1.3.0" 31 | }, 32 | "devDependencies": { 33 | "clean-publish": "^3.4.2", 34 | "eslint": "^8.0.1", 35 | "eslint-plugin-jest": "^25.2.2", 36 | "jest": "^27.3.1", 37 | "postcss": "^8.4.33", 38 | "postcss-cli": "^11.0.0" 39 | }, 40 | "eslintConfig": { 41 | "parserOptions": { 42 | "ecmaVersion": 2017 43 | }, 44 | "env": { 45 | "node": true, 46 | "es6": true 47 | }, 48 | "extends": [ 49 | "eslint:recommended", 50 | "plugin:jest/recommended" 51 | ], 52 | "rules": { 53 | "jest/expect-expect": "off" 54 | } 55 | }, 56 | "jest": { 57 | "coverageThreshold": { 58 | "global": { 59 | "statements": 95 60 | } 61 | } 62 | } 63 | } 64 | --------------------------------------------------------------------------------