diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5b55ae..8d4f0e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 5.2.0 +- Added: `property-no-unknown` rule to disallow unknown properties. (#490). - Added: `at-use-no-redundant-alias` rule to disallow redundant namespace aliases (#445). - Added: `function-calculation-no-interpolation` rule to forbid interpolation in calc functions (#539). diff --git a/README.md b/README.md index 426e1ecf..7de69067 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Please also see the [example configs](./docs/examples/) for special cases. - [`declaration-nested-properties`](./src/rules/declaration-nested-properties/README.md): Require or disallow properties with `-` in their names to be in a form of a nested group. - [`declaration-nested-properties-no-divided-groups`](./src/rules/declaration-nested-properties-no-divided-groups/README.md): Disallow nested properties of the same "namespace" to be divided into multiple groups. +- [`property-no-unknown`](./src/rules/property-no-unknown/README.md): Disallow unknown properties, including [nested properties](https://sass-lang.com/documentation/style-rules/declarations/#nesting). ### Dimension diff --git a/src/rules/index.js b/src/rules/index.js index 292672ca..cdc11795 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -60,6 +60,7 @@ const rules = { "operator-no-unspaced": require("./operator-no-unspaced"), "partial-no-import": require("./partial-no-import"), "percent-placeholder-pattern": require("./percent-placeholder-pattern"), + "property-no-unknown": require("./property-no-unknown"), "selector-nest-combinators": require("./selector-nest-combinators"), "selector-no-redundant-nesting-selector": require("./selector-no-redundant-nesting-selector"), "selector-no-union-class-name": require("./selector-no-union-class-name") diff --git a/src/rules/property-no-unknown/README.md b/src/rules/property-no-unknown/README.md new file mode 100644 index 00000000..90fbdb39 --- /dev/null +++ b/src/rules/property-no-unknown/README.md @@ -0,0 +1,207 @@ +# property-no-unknown + +Disallow unknown properties. Should be used instead of Stylelint's [property-no-unknown](https://stylelint.io/user-guide/rules/property-no-unknown). + + +```scss +a { height: 100%; } +/** ↑ + * This property */ +``` + +This rule considers properties defined in the [CSS Specifications and browser specific properties](https://github.com/betit/known-css-properties#source) to be known. + +This rule ignores: + +- variables (`$sass`, `@less`, `--custom-property`) +- vendor-prefixed properties (e.g., `-moz-align-self`, `-webkit-align-self`) + +Use option `checkPrefixed` described below to turn on checking of vendor-prefixed properties. + +The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule. + +## Options + +### `true` + +The following patterns are considered problems: + + +```scss +a { + colr: blue; +} +``` + + +```scss +a { + my-property: 1; +} +``` + + +```scss +a { + font: { + stuff: bold; + } +} +``` + +The following patterns are _not_ considered problems: + + +```scss +a { + color: green; +} +``` + + +```scss +a { + fill: black; +} +``` + + +```scss +a { + -moz-align-self: center; +} +``` + + +```scss +a { + -webkit-align-self: center; +} +``` + + +```scss +a { + align-self: center; +} +``` + + +```scss +a { + font: { + weight: bold; + } +} +``` + +## Optional secondary options + +### `ignoreProperties: ["/regex/", /regex/, "string"]` + +Given: + +```json +["/^my-/", "custom"] +``` + +The following patterns are _not_ considered problems: + + +```scss +a { + my-property: 10px; +} +``` + + +```scss +a { + my-other-property: 10px; +} +``` + + +```scss +a { + custom: 10px; +} +``` + +### `ignoreSelectors: ["/regex/", /regex/, "string"]` + +Skips checking properties of the given selectors against this rule. + +Given: + +```json +[":root"] +``` + +The following patterns are _not_ considered problems: + + +```scss +:root { + my-property: blue; +} +``` + +### `ignoreAtRules: ["/regex/", /regex/, "string"]` + +Ignores properties nested within specified at-rules. + +Given: + +```json +["supports"] +``` + +The following patterns are _not_ considered problems: + + +```scss +@supports (display: grid) { + a { + my-property: 1; + } +} +``` + +### `checkPrefixed: true | false` (default: `false`) + +If `true`, this rule will check vendor-prefixed properties. + +For example with `true`: + +The following patterns are _not_ considered problems: + + +```scss +a { + -webkit-overflow-scrolling: auto; +} +``` + + +```scss +a { + -moz-box-flex: 0; +} +``` + +The following patterns are considered problems: + + +```scss +a { + -moz-align-self: center; +} +``` + + +```scss +a { + -moz-overflow-scrolling: center; +} +``` diff --git a/src/rules/property-no-unknown/__tests__/index.js b/src/rules/property-no-unknown/__tests__/index.js new file mode 100644 index 00000000..e99c1fbb --- /dev/null +++ b/src/rules/property-no-unknown/__tests__/index.js @@ -0,0 +1,335 @@ +"use strict"; + +const { messages, ruleName } = require(".."); + +testRule({ + ruleName, + config: [true], + + accept: [ + { + code: ".foo { color: green; }" + }, + { + code: ".foo { COLoR: green; }" + }, + { + code: ".foo { fill: black; }" + }, + { + code: ".foo { -webkit-align-self: center; }" + }, + { + code: ".foo { align-self: center; }" + }, + { + code: ".foo { --bg-color: white; }", + description: "ignore standard CSS variables" + }, + { + code: ".foo { -moz-align-self: center; }", + description: "ignore vendor prefixes" + }, + { + code: ".foo { *width: 100px; }", + description: "ignore CSS hacks" + }, + { + code: ".foo { --custom-property-set: { colr: blue; } }", + description: "ignore custom property sets" + } + ], + + reject: [ + { + code: ".foo { colr: blue; }", + message: messages.rejected("colr"), + line: 1, + column: 8, + endLine: 1, + endColumn: 12 + }, + { + code: ".foo { COLR: blue; }", + message: messages.rejected("COLR"), + line: 1, + column: 8, + endLine: 1, + endColumn: 12 + }, + { + code: ".foo {\n colr: blue;\n}", + message: messages.rejected("colr"), + line: 2, + column: 3, + endLine: 2, + endColumn: 7 + }, + { + code: ".foo { *wdth: 100px; }", + message: messages.rejected("wdth"), + line: 1, + column: 8, + endLine: 1, + endColumn: 12 + }, + { + code: ":export { my-property: red; }", + message: messages.rejected("my-property"), + line: 1, + column: 11, + endLine: 1, + endColumn: 22 + } + ] +}); + +testRule({ + ruleName, + customSyntax: "postcss-scss", + config: [true], + + accept: [ + { + code: ".foo { $bgColor: white; }", + description: "ignore SCSS variables" + }, + { + code: ".foo { namespace.$bgColor: white; }", + description: "ignore SCSS variables within namespace" + }, + { + code: ".foo { #{$prop}: black; }", + description: "ignore property interpolation" + }, + { + code: ".foo { border: { style: solid; } }", + description: "ignore nested properties" + } + ] +}); + +testRule({ + ruleName, + customSyntax: "postcss-less", + config: [true], + + accept: [ + { + code: ".foo { @bgColor: white; }", + description: "ignore LESS variables" + }, + { + code: ".foo { @{prop}: black; }", + description: "ignore property interpolation" + }, + { + code: ".foo { transform+: rotate(15deg); }", + description: "Append property value with space using +" + }, + { + code: ".foo { transform+_: rotate(15deg); }", + description: "Append property value with space using +_" + }, + { + code: "@foo: { prop: red; }", + description: "ignore LESS map props" + } + ] +}); + +testRule({ + ruleName, + config: [ + true, + { + ignoreProperties: ["-moz-overflow-scrolling", "/^my-/"], + checkPrefixed: true + } + ], + + accept: [ + { + code: ".foo { -webkit-overflow-scrolling: auto; }" + }, + { + code: ".foo { -moz-overflow-scrolling: auto; }" + }, + { + code: ".foo { my-property: 1; }" + }, + { + code: ".foo { my-other-property: 1; }" + } + ], + + reject: [ + { + code: ".foo { overflow-scrolling: auto; }", + message: messages.rejected("overflow-scrolling"), + line: 1, + column: 8 + }, + { + code: ".foo { not-my-property: 1; }", + message: messages.rejected("not-my-property"), + line: 1, + column: 8 + } + ] +}); + +testRule({ + ruleName, + config: [ + true, + { + ignoreProperties: [/^my-/], + checkPrefixed: true + } + ], + + accept: [ + { + code: ".foo { my-property: 1; }" + } + ], + + reject: [ + { + code: ".foo { not-my-property: 1; }", + message: messages.rejected("not-my-property"), + line: 1, + column: 8 + } + ] +}); + +testRule({ + ruleName, + config: [true, { checkPrefixed: true }], + + accept: [ + { + code: ".foo { -webkit-overflow-scrolling: auto; }" + }, + { + code: ".foo { -moz-box-flex: 0; }" + } + ], + + reject: [ + { + code: ".foo { -moz-overflow-scrolling: auto; }", + message: messages.rejected("-moz-overflow-scrolling"), + line: 1, + column: 8 + }, + { + code: ".foo { -moz-align-self: center; }", + message: messages.rejected("-moz-align-self"), + line: 1, + column: 8 + } + ] +}); + +testRule({ + ruleName, + config: [true, { ignoreSelectors: [":export", ":import"] }], + + accept: [ + { + code: ":export { my-property: 1; }" + } + ], + + reject: [ + { + code: ":not-export { my-property: 1; }", + message: messages.rejected("my-property"), + line: 1, + column: 15 + }, + { + // Non-regex strings must exactly match the parent selector. + code: ':import("path/to/file.css") { my-property: 1; }', + message: messages.rejected("my-property"), + line: 1, + column: 31 + } + ] +}); + +testRule({ + ruleName, + config: [true, { ignoreSelectors: ["/:export/", /^:import/] }], + + accept: [ + { + code: ":export { my-property: 1; }" + }, + { + code: ':import("path/to/file.css") { my-property: 1; }' + } + ], + + reject: [ + { + code: ":exprat { my-property: 1; }", + message: messages.rejected("my-property"), + line: 1, + column: 11 + } + ] +}); + +testRule({ + ruleName, + config: [true, { ignoreAtRules: ["supports", /^my-/] }], + + accept: [ + { + code: "@supports (display: grid) { my-property: 1; }" + }, + { + code: "@my-at-rule { foo: 1; }" + }, + { + code: "@supports (display:grid) { @media (min-width: 10px) { foo: 1; } }" + }, + { + code: "@supports (display:grid) { @media (min-width: 10px) { a { foo: 1; } } }" + }, + { + code: "@my-other-at-rule { a { foo: 1; } }" + } + ], + + reject: [ + { + code: "@media screen { a { foo: 1; } }", + message: messages.rejected("foo"), + line: 1, + column: 21 + }, + { + code: "@not-my-at-rule { foo: 1; }", + message: messages.rejected("foo"), + line: 1, + column: 19 + }, + { + code: "a { foo: 1; }", + message: messages.rejected("foo"), + line: 1, + column: 5 + }, + { + code: "@not-my-at-rule foobar { foo: 1; }", + message: messages.rejected("foo"), + line: 1, + column: 26 + } + ] +}); diff --git a/src/rules/property-no-unknown/index.js b/src/rules/property-no-unknown/index.js new file mode 100644 index 00000000..1230c903 --- /dev/null +++ b/src/rules/property-no-unknown/index.js @@ -0,0 +1,128 @@ +"use strict"; + +const isCustomPropertySet = require("../../utils/isCustomPropertySet"); +const isStandardSyntaxProperty = require("../../utils/isStandardSyntaxProperty"); +const isStandardSyntaxDeclaration = require("../../utils/isStandardSyntaxDeclaration"); +const isType = require("../../utils/isType"); +const optionsMatches = require("../../utils/optionsMatches"); +const namespace = require("../../utils/namespace"); +const ruleUrl = require("../../utils/ruleUrl"); +const properties = require("known-css-properties").all; + +const { utils } = require("stylelint"); +const { isBoolean, isRegExp, isString } = require("../../utils/validateTypes"); + +const ruleName = namespace("property-no-unknown"); + +function vendorPrefix(node) { + const match = node.match(/^(-\w+-)/); + if (match) { + return match[0] || ""; + } + return ""; +} + +const messages = utils.ruleMessages(ruleName, { + rejected: property => `Unexpected unknown property "${property}"` +}); + +const meta = { + url: ruleUrl(ruleName) +}; + +function rule(primary, secondaryOptions) { + const allValidProperties = new Set(properties); + + return (root, result) => { + const validOptions = utils.validateOptions( + result, + ruleName, + { actual: primary }, + { + actual: secondaryOptions, + possible: { + ignoreProperties: [isString, isRegExp], + checkPrefixed: [isBoolean], + ignoreSelectors: [isString, isRegExp], + ignoreAtRules: [isString, isRegExp] + }, + optional: true + } + ); + + if (!validOptions) { + return; + } + + const shouldCheckPrefixed = + secondaryOptions && secondaryOptions.checkPrefixed; + + root.walkDecls(decl => { + const hasNamespace = decl.prop.indexOf("."); + let prop = + hasNamespace > -1 ? decl.prop.slice(hasNamespace + 1) : decl.prop; + + if ( + !isStandardSyntaxProperty(prop) || + !isStandardSyntaxDeclaration(decl) || + isCustomPropertySet(prop) || + prop.startsWith("--") || + (!shouldCheckPrefixed && vendorPrefix(prop)) || + optionsMatches(secondaryOptions, "ignoreProperties", prop) + ) { + return; + } + + const parent = decl.parent; + + if ( + parent && + isType(parent, "rule") && + optionsMatches(secondaryOptions, "ignoreSelectors", parent.selector) + ) { + return; + } + + let node = parent; + + while (node && node.type !== "root") { + if ( + isType(node, "atrule") && + optionsMatches(secondaryOptions, "ignoreAtRules", node.name) + ) { + return; + } + + node = node.parent; + } + + // Nested properties + if ( + parent && + isType(parent, "rule") && + parent.selector && + parent.selector[parent.selector.length - 1] === ":" && + parent.selector.substring(0, 2) !== "--" + ) { + prop = parent.selector.replace(":", "") + "-" + prop; + } + + if (allValidProperties.has(prop.toLowerCase())) { + return; + } + + utils.report({ + message: messages.rejected(prop), + node: decl, + word: prop, + result, + ruleName + }); + }); + }; +} + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule; diff --git a/src/utils/isStandardSyntaxDeclaration.js b/src/utils/isStandardSyntaxDeclaration.js new file mode 100644 index 00000000..f46151bb --- /dev/null +++ b/src/utils/isStandardSyntaxDeclaration.js @@ -0,0 +1,50 @@ +"use strict"; + +/** + * Check whether a declaration is standard + * + * @param {Object} decl + * @returns {boolean} + */ +module.exports = function (decl) { + const prop = decl.prop; + const parent = decl.parent; + + // SCSS var (e.g. $var: x), list (e.g. $list: (x)) or map (e.g. $map: (key:value)) + if (prop.startsWith("$")) { + return true; + } + + // SCSS var within a namespace (e.g. namespace.$var: x) + if (prop.includes(".$")) { + return true; + } + + // Less var (e.g. @var: x), but exclude variable interpolation (e.g. @{var}) + if (prop[0] === "@" && prop[1] !== "{") { + return false; + } + + // Less map declaration + if (parent && parent.type === "atrule" && parent.raws.afterName === ":") { + return false; + } + + // Less map (e.g. #my-map() { myprop: red; }) + if ( + parent && + parent.type === "rule" && + parent.selector && + parent.selector.startsWith("#") && + parent.selector.endsWith("()") + ) { + return false; + } + + // Less &:extend + if ("extend" in decl && decl.extend) { + return false; + } + + return true; +};