diff --git a/src/rules/dimension-no-non-numeric-values/README.md b/src/rules/dimension-no-non-numeric-values/README.md new file mode 100644 index 00000000..985444f9 --- /dev/null +++ b/src/rules/dimension-no-non-numeric-values/README.md @@ -0,0 +1,93 @@ +# dimension-no-non-numeric-values + +Interpolating a value with a unit (e.g. `#{$value}px`) results in a +_string_ value, not as numeric value. This value then cannot be used in +numerical operations. It is better to use arithmetic to apply a unit to a +number (e.g. `$value * 1px`). + +This rule requires that all interpolation for values should be in the format `$value * 1` instead of `#{value}` + +```scss +$value: 4; + +p { + padding: #{value}px; +// ↑ ↑ +// should be $value * 1px +} +``` + +## Options + +### `true` + +The following patterns are considered violations: + +```scss +$value: 4; + +p { + padding: #{value}px; +} +``` + +The following patterns are _not_ considered violations: + +```scss +$value: 4; + +p { + padding: $value * 1px; +} +``` + +## List of units +Font-relative lengths ([link](https://www.w3.org/TR/css-values-4/#font-relative-lengths)) +* em +* ex +* cap +* ch +* ic +* rem +* lh +* rlh + +Viewport-relative lengths ([link](https://www.w3.org/TR/css-values-4/#viewport-relative-lengths)) +* vw +* vh +* vi +* vb +* vmin +* vmax + +Absolute lengths ([link](https://www.w3.org/TR/css-values-4/#absolute-lengths)) +* cm +* mm +* Q +* in +* pc +* pt +* px + +Angle units ([link](https://www.w3.org/TR/css-values-4/#angles)) +* deg +* grad +* rad +* turn + +Duration units ([link](https://www.w3.org/TR/css-values-4/#time)) +* s +* ms + +Frequency units ([link](https://www.w3.org/TR/css-values-4/#frequency)) +* Hz +* kHz + +Resolution units ([link](https://www.w3.org/TR/css-values-4/#resolution)) +* dpi +* dpcm +* dppx +* x + +Flexible lengths ([link](https://www.w3.org/TR/css-grid-1/#fr-unit)) +* fr diff --git a/src/rules/dimension-no-non-numeric-values/__tests__/index.js b/src/rules/dimension-no-non-numeric-values/__tests__/index.js new file mode 100644 index 00000000..b01c8e82 --- /dev/null +++ b/src/rules/dimension-no-non-numeric-values/__tests__/index.js @@ -0,0 +1,59 @@ +import rule, { ruleName, messages, units } from ".."; + +testRule(rule, { + ruleName, + config: [true], + syntax: "scss", + accept: loopOverUnits({ + code: ` + p { + padding: 1 * 1%unit%; + } + `, + description: "Accepts proper value interpolation with %unit%" + }).concat([ + { + code: "$pad: 2; $doublePad: px#{$pad}px;", + description: "does not report when a unit is preceded by another string" + }, + { + code: "$pad: 2; $doublePad: #{$pad}pxx;", + description: "does not report lint when no understood units are used" + }, + { + code: `$pad: "2"; + $string: "#{$pad}px";`, + description: "does not report lint when string is quoted" + } + ]), + reject: loopOverUnits({ + code: ` + p { + padding: #{$value}%unit%; + } + `, + messages: messages.rejected, + description: "Rejects interpolation with %unit%" + }).concat([ + { + code: "$pad: 2; $padAndMore: #{$pad + 5}px;", + description: "reports lint when expression used in interpolation", + messages: messages.rejected("px") + } + ]) +}); + +function loopOverUnits(codeBlock) { + return units.map(unit => { + const block = { + code: codeBlock.code.replace("%unit%", unit), + description: codeBlock.description.replace("%unit%", unit) + }; + + if (codeBlock.messages) { + block["messages"] = codeBlock.messages.call(unit); + } + + return block; + }); +} diff --git a/src/rules/dimension-no-non-numeric-values/index.js b/src/rules/dimension-no-non-numeric-values/index.js new file mode 100644 index 00000000..13c3738c --- /dev/null +++ b/src/rules/dimension-no-non-numeric-values/index.js @@ -0,0 +1,124 @@ +import { utils } from "stylelint"; +import { namespace } from "../../utils"; +import valueParser from "postcss-value-parser"; + +export const ruleName = namespace("dimension-no-non-numeric-values"); + +export const messages = utils.ruleMessages(ruleName, { + rejected: unit => + `Expected "$value * 1${unit}" instead of "#{value}${unit}". Consider writing "value" in terms of ${unit} originally.` +}); + +export const units = [ + // Font-relative lengths: + // https://www.w3.org/TR/css-values-4/#font-relative-lengths + "em", + "ex", + "cap", + "ch", + "ic", + "rem", + "lh", + "rlh", + + // Viewport-relative lengths: + // https://www.w3.org/TR/css-values-4/#viewport-relative-lengths + "vw", + "vh", + "vi", + "vb", + "vmin", + "vmax", + + // Absolute lengths: + // https://www.w3.org/TR/css-values-4/#absolute-lengths + "cm", + "mm", + "Q", + "in", + "pc", + "pt", + "px", + + // Angle units: + // https://www.w3.org/TR/css-values-4/#angles + "deg", + "grad", + "rad", + "turn", + + // Duration units: + // https://www.w3.org/TR/css-values-4/#time + "s", + "ms", + + // Frequency units: + // https://www.w3.org/TR/css-values-4/#frequency + "Hz", + "kHz", + + // Resolution units: + // https://www.w3.org/TR/css-values-4/#resolution + "dpi", + "dpcm", + "dppx", + "x", + + // Flexible lengths: + // https://www.w3.org/TR/css-grid-1/#fr-unit + "fr" +]; + +export default function rule(primary) { + return (root, result) => { + const validOptions = utils.validateOptions(result, ruleName, { + actual: primary + }); + + if (!validOptions) { + return; + } + + root.walkDecls(decl => { + valueParser(decl.value).walk(node => { + // All words are non-quoted, while strings are quoted. + // If quoted, it's probably a deliberate non-numeric dimension. + if (node.type !== "word") { + return; + } + + if (!isInterpolated(node.value)) { + return; + } + + utils.report({ + ruleName, + result, + message: messages.rejected, + node: decl + }); + }); + }); + }; +} + +function isInterpolated(value) { + let boolean = false; + + // ValueParser breaks up interpolation with math into multiple, fragmented + // segments (#{$value, +, 2}px). The easiest way to detect this is to look for a fragmented + // interpolated section. + if (value.match(/^#{\$[a-z]*$/)) { + return true; + } + + units.forEach(unit => { + const regex = new RegExp("^#{[$a-z_0-9 +-]*}" + unit + ";?$"); + + if (value.match(regex)) { + boolean = true; + } + }); + + return boolean; +} diff --git a/src/rules/index.js b/src/rules/index.js index f316b694..3b0edbc1 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -19,6 +19,7 @@ import atEachKeyValue from "./at-each-key-value-single-line"; import atRuleNoUnknown from "./at-rule-no-unknown"; import declarationNestedProperties from "./declaration-nested-properties"; import declarationNestedPropertiesNoDividedGroups from "./declaration-nested-properties-no-divided-groups"; +import dimensionNoNonNumeric from "./dimension-no-non-numeric-values"; import dollarVariableColonNewlineAfter from "./dollar-variable-colon-newline-after"; import dollarVariableColonSpaceAfter from "./dollar-variable-colon-space-after"; import dollarVariableColonSpaceBefore from "./dollar-variable-colon-space-before"; @@ -63,6 +64,7 @@ export default { "at-rule-no-unknown": atRuleNoUnknown, "declaration-nested-properties": declarationNestedProperties, "declaration-nested-properties-no-divided-groups": declarationNestedPropertiesNoDividedGroups, + "dimension-no-non-numeric-values": dimensionNoNonNumeric, "dollar-variable-colon-newline-after": dollarVariableColonNewlineAfter, "dollar-variable-colon-space-after": dollarVariableColonSpaceAfter, "dollar-variable-colon-space-before": dollarVariableColonSpaceBefore,