├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── main.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc ├── LICENSE ├── README.md ├── commitlint.config.js ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json └── src ├── index.js ├── rules └── use-defensive-css │ ├── base.js │ ├── index.js │ └── index.test.js └── utils ├── findCustomProperties.js ├── findShorthandBackgroundRepeat.js └── findVendorPrefixes.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "overrides": [], 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "rules": {} 14 | } 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: yuschick 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📒 Description 2 | 3 | > Write a general description and summary of the changes 4 | 5 | ## 🚀 Changes 6 | 7 | > List of changes this pull request includes 8 | 9 | 14 | 15 | ## 🔐 Closes 16 | 17 | > Include a link to a specific Github issue this pull request closes. 18 | 19 | ## ⛳️ Testing 20 | 21 | > List of tests completed to verify the change 22 | 23 | 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install Dependencies 18 | run: yarn 19 | 20 | - name: Run Plugin Tests 21 | run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 80, 4 | "proseWrap": "always", 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Fedya Petrakov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🦖 Stylelint Plugin Defensive CSS 2 | 3 | ![License](https://img.shields.io/github/license/yuschick/stylelint-plugin-defensive-css?style=for-the-badge) 4 | ![NPM Version](https://img.shields.io/npm/v/stylelint-plugin-defensive-css?style=for-the-badge) 5 | ![Main Workflow Status](https://img.shields.io/github/actions/workflow/status/yuschick/stylelint-plugin-defensive-css/main.yaml?style=for-the-badge) 6 | 7 | A Stylelint plugin to enforce defensive CSS best practices. 8 | 9 | > [Read more about Defensive CSS](https://defensivecss.dev/) 10 | 11 | ## 🚀 Version 1.0.0 12 | 13 | With the release of version 1.0.0 of the plugin, we now support Stylelint 16. 14 | 15 | --- 16 | 17 | ## Getting Started 18 | 19 | > Before getting started with the plugin, you must first have 20 | > [Stylelint](https://stylelint.io/) version 14.0.0 or greater installed 21 | 22 | To get started using the plugin, it must first be installed. 23 | 24 | ```bash 25 | npm i stylelint-plugin-defensive-css --save-dev 26 | ``` 27 | 28 | ```bash 29 | yarn add stylelint-plugin-defensive-css --dev 30 | ``` 31 | 32 | With the plugin installed, the rule(s) can be added to the project's Stylelint 33 | configuration. 34 | 35 | ```json 36 | { 37 | "plugins": ["stylelint-plugin-defensive-css"], 38 | "rules": { 39 | "plugin/use-defensive-css": [true, { "severity": "warning" }] 40 | } 41 | } 42 | ``` 43 | 44 | ## Rules / Options 45 | 46 | The plugin provides multiple rules that can be toggled on and off as needed. 47 | 48 | 1. [Accidental Hover](#accidental-hover) 49 | 2. [Background-Repeat](#background-repeat) 50 | 3. [Custom Property Fallbacks](#custom-property-fallbacks) 51 | 4. [Flex Wrapping](#flex-wrapping) 52 | 5. [Scroll Chaining](#scroll-chaining) 53 | 6. [Scrollbar Gutter](#scrollbar-gutter) 54 | 7. [Vendor Prefix Grouping](#vendor-prefix-grouping) 55 | 56 | --- 57 | 58 | ### Accidental Hover 59 | 60 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/hover-media/) 61 | 62 | We use hover effects to provide an indication to the user that an element is 63 | clickable or active. That is fine for devices that have a mouse or a trackpad. 64 | However, for mobile browsing hover effects can get confusing. 65 | 66 | Enable this rule in order to prevent unintentional hover effects on mobile 67 | devices. 68 | 69 | ```json 70 | { 71 | "rules": { 72 | "plugin/use-defensive-css": [true, { "accidental-hover": true }] 73 | } 74 | } 75 | ``` 76 | 77 | #### ✅ Passing Examples 78 | 79 | ```css 80 | @media (hover: hover) { 81 | .btn:hover { 82 | color: black; 83 | } 84 | } 85 | 86 | /* Will traverse nested media queries */ 87 | @media (hover: hover) { 88 | @media (min-width: 1px) { 89 | .btn:hover { 90 | color: black; 91 | } 92 | } 93 | } 94 | 95 | /* Will traverse nested media queries */ 96 | @media (min-width: 1px) { 97 | @media (hover: hover) { 98 | @media (min-width: 100px) { 99 | .btn:hover { 100 | color: black; 101 | } 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | #### ❌ Failing Examples 108 | 109 | ```css 110 | .fail-btn:hover { 111 | color: black; 112 | } 113 | 114 | @media (min-width: 1px) { 115 | .fail-btn:hover { 116 | color: black; 117 | } 118 | } 119 | ``` 120 | 121 | ### Background Repeat 122 | 123 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/bg-repeat/) 124 | 125 | Oftentimes, when using a large image as a background, we tend to forget to 126 | account for the case when the design is viewed on a large screen. That 127 | background will repeat by default. 128 | 129 | Enable this rule in order to prevent unintentional repeating background. 130 | 131 | ```json 132 | { 133 | "rules": { 134 | "plugin/use-defensive-css": [true, { "background-repeat": true }] 135 | } 136 | } 137 | ``` 138 | 139 | #### ✅ Passing Examples 140 | 141 | ```css 142 | div { 143 | background: url('some-image.jpg') repeat black top center; 144 | } 145 | div { 146 | background: url('some-image.jpg') black top center; 147 | background-repeat: no-repeat; 148 | } 149 | ``` 150 | 151 | #### ❌ Failing Examples 152 | 153 | ```css 154 | div { 155 | background: url('some-image.jpg') black top center; 156 | } 157 | div { 158 | background-image: url('some-image.jpg'); 159 | } 160 | ``` 161 | 162 | ### Custom Property Fallbacks 163 | 164 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/css-variable-fallback/) 165 | 166 | CSS variables are gaining more and more usage in web design. There is a method 167 | that we can apply to use them in a way that doesn’t break the experience, in 168 | case the CSS variable value was empty for some reason. 169 | 170 | Enable this rule in order to require fallbacks values for custom properties. 171 | 172 | ```json 173 | { 174 | "rules": { 175 | "plugin/use-defensive-css": [true, { "custom-property-fallbacks": true }] 176 | } 177 | } 178 | ``` 179 | 180 | #### ✅ Passing Examples 181 | 182 | ```css 183 | div { 184 | color: var(--color-primary, #000); 185 | } 186 | ``` 187 | 188 | #### ❌ Failing Examples 189 | 190 | ```css 191 | div { 192 | color: var(--color-primary); 193 | } 194 | ``` 195 | 196 | | Option | Description | 197 | | ------ | ------------------------------------------------------------------------------------------------- | 198 | | ignore | Pass an array of regular expressions and/or strings to ignore linting specific custom properties. | 199 | 200 | ```json 201 | { 202 | "rules": { 203 | "plugin/use-defensive-css": [ 204 | true, 205 | { "custom-property-fallbacks": [true, { "ignore": [/hel-/, "theme-"] }] } 206 | ] 207 | } 208 | } 209 | ``` 210 | 211 | The `ignore` array can support regular expressions and strings. If a string is 212 | provided, it will be translated into a RegExp like `new RegExp(string)` before 213 | testing the custom property name. 214 | 215 | #### ✅ Passing Examples 216 | 217 | ```css 218 | div { 219 | /* properties with theme- are ignored */ 220 | color: var(--theme-color-primary); 221 | 222 | /* properties with hel- are ignored */ 223 | padding: var(--hel-spacing-200); 224 | } 225 | ``` 226 | 227 | ### Flex Wrapping 228 | 229 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/flexbox-wrapping/) 230 | 231 | CSS flexbox is one of the most useful CSS layout features nowadays. It’s 232 | tempting to add `display: flex` to a wrapper and have the child items ordered 233 | next to each other. The thing is when there is not enough space, those child 234 | items won’t wrap into a new line by default. We need to either change that 235 | behavior with `flex-wrap: wrap` or explicitly define `nowrap` on the container. 236 | 237 | Enable this rule in order to require all flex rows to have a flex-wrap value. 238 | 239 | ```json 240 | { 241 | "rules": { 242 | "plugin/use-defensive-css": [true, { "flex-wrapping": true }] 243 | } 244 | } 245 | ``` 246 | 247 | #### ✅ Passing Examples 248 | 249 | ```css 250 | div { 251 | display: flex; 252 | flex-wrap: wrap; 253 | } 254 | div { 255 | display: flex; 256 | flex-wrap: nowrap; 257 | } 258 | div { 259 | display: flex; 260 | flex-direction: row-reverse; 261 | flex-wrap: wrap-reverse; 262 | } 263 | div { 264 | display: flex; 265 | flex-flow: row wrap; 266 | } 267 | div { 268 | display: flex; 269 | flex-flow: row-reverse nowrap; 270 | } 271 | ``` 272 | 273 | #### ❌ Failing Examples 274 | 275 | ```css 276 | div { 277 | display: flex; 278 | } 279 | div { 280 | display: flex; 281 | flex-direction: row; 282 | } 283 | div { 284 | display: flex; 285 | flex-flow: row; 286 | } 287 | ``` 288 | 289 | ### Scroll Chaining 290 | 291 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/scroll-chain/) 292 | 293 | Have you ever opened a modal and started scrolling, and then when you reach the 294 | end and keep scrolling, the content underneath the modal (the body element) will 295 | scroll? This is called scroll chaining. 296 | 297 | Enable this rule in order to require all scrollable overflow properties to have 298 | an overscroll-behavior value. 299 | 300 | ```json 301 | { 302 | "rules": { 303 | "plugin/use-defensive-css": [true, { "scroll-chaining": true }] 304 | } 305 | } 306 | ``` 307 | 308 | #### ✅ Passing Examples 309 | 310 | ```css 311 | div { 312 | overflow-x: auto; 313 | overscroll-behavior-x: contain; 314 | } 315 | 316 | div { 317 | overflow: hidden scroll; 318 | overscroll-behavior: contain; 319 | } 320 | 321 | div { 322 | overflow: hidden; /* No overscroll-behavior is needed in the case of hidden */ 323 | } 324 | 325 | div { 326 | overflow-block: auto; 327 | overscroll-behavior: none; 328 | } 329 | ``` 330 | 331 | #### ❌ Failing Examples 332 | 333 | ```css 334 | div { 335 | overflow-x: auto; 336 | } 337 | 338 | div { 339 | overflow: hidden scroll; 340 | } 341 | 342 | div { 343 | overflow-block: auto; 344 | } 345 | ``` 346 | 347 | ### Scrollbar Gutter 348 | 349 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/scrollbar-gutter/) 350 | 351 | Imagine a container with only a small amount of content with no need to scroll. 352 | The content would be aligned evenly within the boundaries of its container. Now, 353 | if that container has more content added, and a scrollbar appears, that 354 | scrollbar will cause a layout shift, forcing the content to reflow and jump. 355 | This behavior can be jarring. 356 | 357 | To avoid layout shifting with variable content, enforce that a 358 | `scrollbar-gutter` property is defined for any scrollable container. 359 | 360 | ```json 361 | { 362 | "rules": { 363 | "plugin/use-defensive-css": [true, { "scrollbar-gutter": true }] 364 | } 365 | } 366 | ``` 367 | 368 | #### ✅ Passing Examples 369 | 370 | ```css 371 | div { 372 | overflow-x: auto; 373 | scrollbar-gutter: auto; 374 | } 375 | 376 | div { 377 | overflow: hidden scroll; 378 | scrollbar-gutter: stable; 379 | } 380 | 381 | div { 382 | overflow: hidden; /* No scrollbar-gutter is needed in the case of hidden */ 383 | } 384 | 385 | div { 386 | overflow-block: auto; 387 | scrollbar-gutter: stable both-edges; 388 | } 389 | ``` 390 | 391 | #### ❌ Failing Examples 392 | 393 | ```css 394 | div { 395 | overflow-x: auto; 396 | } 397 | 398 | div { 399 | overflow: hidden scroll; 400 | } 401 | 402 | div { 403 | overflow-block: auto; 404 | } 405 | ``` 406 | 407 | ### Vendor Prefix Grouping 408 | 409 | > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/grouping-selectors/) 410 | 411 | It's not recommended to group selectors that are meant to work with different 412 | browsers. For example, styling an input's placeholder needs multiple selectors 413 | per the browser. If we group the selectors, the entire rule will be invalid, 414 | according to [w3c](https://www.w3.org/TR/selectors/#grouping). 415 | 416 | Enable this rule in order to require all vendor-prefixed selectors to be split 417 | into their own rules. 418 | 419 | ```json 420 | { 421 | "rules": { 422 | "plugin/use-defensive-css": [true, { "vendor-prefix-grouping": true }] 423 | } 424 | } 425 | ``` 426 | 427 | #### ✅ Passing Examples 428 | 429 | ```css 430 | input::-webkit-input-placeholder { 431 | color: #222; 432 | } 433 | input::-moz-placeholder { 434 | color: #222; 435 | } 436 | ``` 437 | 438 | #### ❌ Failing Examples 439 | 440 | ```css 441 | input::-webkit-input-placeholder, 442 | input::-moz-placeholder { 443 | color: #222; 444 | } 445 | ``` 446 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | preset: 'jest-preset-stylelint', 4 | roots: ['src'], 5 | runner: 'jest-light-runner', 6 | setupFiles: ['./jest.setup.js'], 7 | testEnvironment: 'node', 8 | }; 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { getTestRule } from 'jest-preset-stylelint'; 2 | 3 | global.testRule = getTestRule({ 4 | plugins: ['./src'], 5 | }); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylelint-plugin-defensive-css", 3 | "version": "1.0.4", 4 | "description": "A Stylelint plugin to enforce defensive CSS best practices.", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "exports": "./src/index.js", 8 | "files": [ 9 | "src/**/*.js", 10 | "!**/**/*.test.js" 11 | ], 12 | "scripts": { 13 | "prepare": "husky install", 14 | "jest": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --runInBand", 15 | "test": "npm run jest", 16 | "test:watch": "npm run jest -- --watch" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/yuschick/stylelint-plugin-defensive-css.git" 21 | }, 22 | "author": "Daniel Yuschick", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/yuschick/stylelint-plugin-defensive-css/issues" 26 | }, 27 | "homepage": "https://github.com/yuschick/stylelint-plugin-defensive-css#readme", 28 | "engines": { 29 | "node": ">=18.12.0" 30 | }, 31 | "keywords": [ 32 | "css", 33 | "csslint", 34 | "defensive css", 35 | "lint", 36 | "linter", 37 | "stylelint", 38 | "stylelint-plugin" 39 | ], 40 | "peerDependencies": { 41 | "stylelint": "^14.0.0 || ^15.0.0 || ^16.0.0" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^19.5.0", 45 | "@commitlint/config-conventional": "^19.5.0", 46 | "cross-env": "^7.0.3", 47 | "eslint": "^9.14.0", 48 | "husky": "^9.1.6", 49 | "jest": "^29.4.3", 50 | "jest-cli": "^29.4.3", 51 | "jest-light-runner": "^0.6.0", 52 | "jest-preset-stylelint": "^7.0.0", 53 | "lint-staged": "^15.0.2", 54 | "prettier": "^3.0.3", 55 | "prettier-eslint": "^16.1.2", 56 | "stylelint": "^16.1.0" 57 | }, 58 | "lint-staged": { 59 | "**/*.js|md|json": [ 60 | "eslint", 61 | "prettier --write" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import useDefensiveCSS from './rules/use-defensive-css/index.js'; 2 | 3 | export default [useDefensiveCSS]; 4 | -------------------------------------------------------------------------------- /src/rules/use-defensive-css/base.js: -------------------------------------------------------------------------------- 1 | import stylelint from 'stylelint'; 2 | 3 | export const ruleName = 'plugin/use-defensive-css'; 4 | 5 | export const ruleMessages = stylelint.utils.ruleMessages(ruleName, { 6 | accidentalHover() { 7 | return 'To prevent accidental hover states on mobile devices, wrap `:hover` selectors inside a `@media (hover: hover) { ...your styles }` query. Learn more: https://defensivecss.dev/tip/hover-media/'; 8 | }, 9 | backgroundRepeat() { 10 | return 'Whenever setting a background image, be sure to explicitly define a `background-repeat` value. Learn more: https://defensivecss.dev/tip/bg-repeat/'; 11 | }, 12 | customPropertyFallbacks() { 13 | return 'Provide a fallback value for a custom property like `var(--your-custom-property, #000000)` to prevent issues in the event the custom property is not defined. Learn more: https://defensivecss.dev/tip/css-variable-fallback/'; 14 | }, 15 | flexWrapping() { 16 | return 'Whenever setting an element to `display: flex` a `flex-wrap` value must be defined. Set `flex-wrap: nowrap` for the default behavior. Learn more: https://defensivecss.dev/tip/flexbox-wrapping/'; 17 | }, 18 | scrollChaining() { 19 | return 'To prevent scroll chaining between contexts, any container with a scrollable overflow must have a `overscroll-behavior` value defined. Learn more: https://defensivecss.dev/tip/scroll-chain/'; 20 | }, 21 | scrollbarGutter() { 22 | return 'To prevent potential layout shifts, any container with a scrollable overflow must have a `scrollbar-gutter` value defined. Learn more: https://defensivecss.dev/tip/scrollbar-gutter/'; 23 | }, 24 | vendorPrefixWGrouping() { 25 | return `To prevent invalid rules in unsupported environments, split each vendor prefix into its own, individual rule. Learn more: https://defensivecss.dev/tip/grouping-selectors/`; 26 | }, 27 | }); 28 | 29 | export const ruleMeta = { 30 | url: 'https://github.com/yuschick/stylelint-plugin-defensive-css', 31 | }; 32 | -------------------------------------------------------------------------------- /src/rules/use-defensive-css/index.js: -------------------------------------------------------------------------------- 1 | import stylelint from 'stylelint'; 2 | 3 | import { ruleName, ruleMessages, ruleMeta } from './base.js'; 4 | import { findShorthandBackgroundRepeat } from '../../utils/findShorthandBackgroundRepeat.js'; 5 | import { findVendorPrefixes } from '../../utils/findVendorPrefixes.js'; 6 | import { findCustomProperties } from '../../utils/findCustomProperties.js'; 7 | 8 | const defaultBackgroundRepeatProps = { 9 | hasBackgroundImage: false, 10 | isMissingBackgroundRepeat: true, 11 | nodeToReport: undefined, 12 | }; 13 | const defaultFlexWrappingProps = { 14 | isDisplayFlex: false, 15 | isFlexRow: true, 16 | isMissingFlexWrap: true, 17 | nodeToReport: undefined, 18 | }; 19 | const defaultScrollbarGutterProps = { 20 | hasOverflow: false, 21 | hasScrollbarGutter: false, 22 | nodeToReport: undefined, 23 | }; 24 | const defaultScrollChainingProps = { 25 | hasOverflow: false, 26 | hasOverscrollBehavior: false, 27 | nodeToReport: undefined, 28 | }; 29 | 30 | let backgroundRepeatProps = { ...defaultBackgroundRepeatProps }; 31 | let flexWrappingProps = { ...defaultFlexWrappingProps }; 32 | let scrollbarGutterProps = { ...defaultScrollbarGutterProps }; 33 | let scrollChainingProps = { ...defaultScrollChainingProps }; 34 | let isLastStyleDeclaration = false; 35 | let isWrappedInHoverAtRule = false; 36 | 37 | const overflowProperties = [ 38 | 'overflow', 39 | 'overflow-x', 40 | 'overflow-y', 41 | 'overflow-inline', 42 | 'overflow-block', 43 | ]; 44 | 45 | function traverseParentRules(parent) { 46 | if (parent.parent.type === 'root') { 47 | return; 48 | } 49 | 50 | if (parent.parent.params && /hover(: hover)?/.test(parent.parent.params)) { 51 | isWrappedInHoverAtRule = true; 52 | } else { 53 | traverseParentRules(parent.parent); 54 | } 55 | } 56 | 57 | const ruleFunction = (_, options) => { 58 | return (root, result) => { 59 | const validOptions = stylelint.utils.validateOptions(result, ruleName); 60 | 61 | if (!validOptions) { 62 | return; 63 | } 64 | 65 | root.walkDecls((decl) => { 66 | isLastStyleDeclaration = 67 | JSON.stringify(decl) === 68 | JSON.stringify(decl.parent.nodes[decl.parent.nodes.length - 1]); 69 | 70 | /* ACCIDENTAL HOVER */ 71 | if (options?.['accidental-hover']) { 72 | const parent = decl.parent; 73 | const selector = parent.selector; 74 | const isHoverSelector = selector?.includes(':hover'); 75 | isWrappedInHoverAtRule = false; 76 | 77 | // If the :hover selector is inside a :not() selector, ignore it 78 | if (/:not\(([^)]*:hover[^)]*)\)/g.test(selector)) { 79 | return; 80 | } 81 | 82 | if (isHoverSelector) { 83 | traverseParentRules(parent); 84 | 85 | if (!isWrappedInHoverAtRule) { 86 | stylelint.utils.report({ 87 | message: ruleMessages.accidentalHover(), 88 | node: decl.parent, 89 | result, 90 | ruleName, 91 | }); 92 | } 93 | } 94 | } 95 | 96 | /* BACKGROUND REPEAT */ 97 | if (options?.['background-repeat']) { 98 | if (decl.prop === 'background' && decl.value.includes('url(')) { 99 | backgroundRepeatProps.hasBackgroundImage = true; 100 | backgroundRepeatProps.isMissingBackgroundRepeat = 101 | !findShorthandBackgroundRepeat(decl.value); 102 | backgroundRepeatProps.nodeToReport = decl; 103 | } 104 | 105 | if (decl.prop === 'background-image' && decl.value.includes('url(')) { 106 | backgroundRepeatProps.hasBackgroundImage = true; 107 | backgroundRepeatProps.nodeToReport = decl; 108 | } 109 | 110 | if (decl.prop === 'background-repeat') { 111 | backgroundRepeatProps.isMissingBackgroundRepeat = false; 112 | } 113 | 114 | if (isLastStyleDeclaration) { 115 | if (Object.values(backgroundRepeatProps).every((prop) => prop)) { 116 | stylelint.utils.report({ 117 | message: ruleMessages.backgroundRepeat(), 118 | node: backgroundRepeatProps.nodeToReport, 119 | result, 120 | ruleName, 121 | }); 122 | } 123 | 124 | backgroundRepeatProps = { ...defaultBackgroundRepeatProps }; 125 | } 126 | } 127 | 128 | /* CUSTOM PROPERTY FALLBACKS */ 129 | if (options?.['custom-property-fallbacks']) { 130 | const propertiesWithoutFallback = findCustomProperties(decl.value); 131 | 132 | if (propertiesWithoutFallback.length) { 133 | if (Array.isArray(options?.['custom-property-fallbacks'])) { 134 | if (options['custom-property-fallbacks'][0]) { 135 | const patterns = options['custom-property-fallbacks'][1].ignore; 136 | const patternMatched = propertiesWithoutFallback.some( 137 | (property) => { 138 | return patterns.some((pattern) => 139 | typeof pattern === 'string' 140 | ? new RegExp(pattern).test(property) 141 | : pattern.test(property), 142 | ); 143 | }, 144 | ); 145 | 146 | if (patternMatched) { 147 | return; 148 | } 149 | } else { 150 | return; 151 | } 152 | } 153 | 154 | stylelint.utils.report({ 155 | message: ruleMessages.customPropertyFallbacks(), 156 | node: decl, 157 | result, 158 | ruleName, 159 | }); 160 | } 161 | } 162 | 163 | /* FLEX WRAPPING */ 164 | if (options?.['flex-wrapping']) { 165 | if (decl.prop === 'display' && decl.value.includes('flex')) { 166 | flexWrappingProps.isDisplayFlex = true; 167 | flexWrappingProps.nodeToReport = decl; 168 | } 169 | 170 | if (decl.prop === 'flex-flow' && decl.value.includes('column')) { 171 | flexWrappingProps.isFlexRow = false; 172 | flexWrappingProps.isMissingFlexWrap = false; 173 | } 174 | 175 | if (decl.prop === 'flex-direction' && decl.value.includes('column')) { 176 | flexWrappingProps.isFlexRow = false; 177 | } 178 | 179 | if ( 180 | decl.prop === 'flex-wrap' || 181 | (decl.prop === 'flex-flow' && decl.value.includes('wrap')) 182 | ) { 183 | flexWrappingProps.isMissingFlexWrap = false; 184 | } 185 | 186 | if (isLastStyleDeclaration) { 187 | if (Object.values(flexWrappingProps).every((prop) => prop)) { 188 | stylelint.utils.report({ 189 | message: ruleMessages.flexWrapping(), 190 | node: flexWrappingProps.nodeToReport, 191 | result, 192 | ruleName, 193 | }); 194 | } 195 | 196 | flexWrappingProps = { ...defaultFlexWrappingProps }; 197 | } 198 | } 199 | 200 | /* SCROLL CHAINING */ 201 | if (options?.['scroll-chaining']) { 202 | if ( 203 | overflowProperties.includes(decl.prop) && 204 | (decl.value.includes('auto') || decl.value.includes('scroll')) 205 | ) { 206 | scrollChainingProps.hasOverflow = true; 207 | scrollChainingProps.nodeToReport = decl; 208 | } 209 | 210 | if (decl.prop.includes('overscroll-behavior')) { 211 | scrollChainingProps.hasOverscrollBehavior = true; 212 | } 213 | 214 | if (isLastStyleDeclaration) { 215 | if ( 216 | scrollChainingProps.hasOverflow && 217 | !scrollChainingProps.hasOverscrollBehavior 218 | ) { 219 | stylelint.utils.report({ 220 | message: ruleMessages.scrollChaining(), 221 | node: scrollChainingProps.nodeToReport, 222 | result, 223 | ruleName, 224 | }); 225 | } 226 | 227 | scrollChainingProps = { ...defaultScrollChainingProps }; 228 | } 229 | } 230 | 231 | /* SCROLLBAR GUTTER */ 232 | if (options?.['scrollbar-gutter']) { 233 | if ( 234 | overflowProperties.includes(decl.prop) && 235 | (decl.value.includes('auto') || decl.value.includes('scroll')) 236 | ) { 237 | scrollbarGutterProps.hasOverflow = true; 238 | scrollbarGutterProps.nodeToReport = decl; 239 | } 240 | 241 | if (decl.prop.includes('scrollbar-gutter')) { 242 | scrollbarGutterProps.hasScrollbarGutter = true; 243 | } 244 | 245 | if (isLastStyleDeclaration) { 246 | if ( 247 | scrollbarGutterProps.hasOverflow && 248 | !scrollbarGutterProps.hasScrollbarGutter 249 | ) { 250 | stylelint.utils.report({ 251 | message: ruleMessages.scrollbarGutter(), 252 | node: scrollbarGutterProps.nodeToReport, 253 | result, 254 | ruleName, 255 | }); 256 | } 257 | 258 | scrollbarGutterProps = { ...defaultScrollbarGutterProps }; 259 | } 260 | } 261 | 262 | /* VENDOR PREFIX GROUPING */ 263 | if (options?.['vendor-prefix-grouping']) { 264 | const hasMultiplePrefixes = findVendorPrefixes(decl.parent.selector); 265 | 266 | if (hasMultiplePrefixes) { 267 | stylelint.utils.report({ 268 | message: ruleMessages.vendorPrefixWGrouping(), 269 | node: decl.parent, 270 | result, 271 | ruleName, 272 | }); 273 | } 274 | } 275 | 276 | return; 277 | }); 278 | }; 279 | }; 280 | 281 | ruleFunction.ruleName = ruleName; 282 | ruleFunction.messages = ruleMessages; 283 | ruleFunction.meta = ruleMeta; 284 | 285 | export default stylelint.createPlugin(ruleName, ruleFunction); 286 | -------------------------------------------------------------------------------- /src/rules/use-defensive-css/index.test.js: -------------------------------------------------------------------------------- 1 | import rule from './index.js'; 2 | const { messages, ruleName } = rule.rule; 3 | 4 | /* eslint-disable-next-line no-undef */ 5 | testRule({ 6 | ruleName, 7 | config: [true, { 'accidental-hover': true }], 8 | plugins: ['./index.js'], 9 | accept: [ 10 | { 11 | code: `@media (hover: hover) { .btn:hover { color: black; } }`, 12 | description: 'Use media query for button hover state.', 13 | }, 14 | { 15 | code: `@media ( hover: hover ) { .btn:hover { color: black; } }`, 16 | description: 'Use media query for button hover state with spaces.', 17 | }, 18 | { 19 | code: `@media (hover) { .btn:hover { color: black; } }`, 20 | description: 'Use shorthand media query for button hover state.', 21 | }, 22 | { 23 | code: `@media (min-width: 1px) { @media (hover: hover) { .btn:hover { color: black; } } }`, 24 | description: 'Use nested media queries for button hover state.', 25 | }, 26 | { 27 | code: `@media (hover: hover) { @media (min-width: 1px) { .btn:hover { color: black; } } }`, 28 | description: 29 | 'Use nested media queries with hover as the parent for button hover state.', 30 | }, 31 | { 32 | code: `@media (min-width: 1px) { @media (hover: hover) { @media (min-width: 1px) { .btn:hover { color: black; } } } }`, 33 | description: 34 | 'Use nested media queries with hover in the middle for button hover state.', 35 | }, 36 | { 37 | code: `@media all and (hover: hover) and (max-width: 699px) { .btn:hover { color: black; } }`, 38 | description: 39 | 'Use nested media queries with hover in the middle for button hover state.', 40 | }, 41 | { 42 | code: `div:not(:hover) { color: red; }`, 43 | description: 'Use :hover selector inside of :not() selector.', 44 | }, 45 | { 46 | code: `div:not(:focus-visible, :hover) { color: red; }`, 47 | description: 'Use :hover selector inside of a grouped :not() selector.', 48 | }, 49 | { 50 | code: `web-component-name { 51 | &:defined { 52 | @media ( hover: hover ) { 53 | details:not( [open] ) { 54 | position: relative; 55 | z-index: 1; 56 | 57 | &::before { 58 | content: ''; 59 | position: absolute; 60 | top: 0; 61 | left: 0; 62 | z-index: -1; 63 | width: 100%; 64 | height: 100%; 65 | background-color: #EFEFF0; 66 | transition: opacity 0.2s; 67 | opacity: 0; 68 | } 69 | 70 | &:hover::before { 71 | opacity: 1; 72 | } 73 | } 74 | } 75 | } 76 | }`, 77 | description: 'False positive complex example', 78 | }, 79 | ], 80 | 81 | reject: [ 82 | { 83 | code: `.fail-btn:hover { color: black; }`, 84 | description: 'Use a hover pseudo selector not inside of a media query.', 85 | message: messages.accidentalHover(), 86 | }, 87 | { 88 | code: `@media (min-width: 1px) { .btn:hover { color: black; } }`, 89 | description: 90 | 'Use a hover pseudo selector inside of a min-width media query.', 91 | message: messages.accidentalHover(), 92 | }, 93 | ], 94 | }); 95 | 96 | /* eslint-disable-next-line no-undef */ 97 | testRule({ 98 | ruleName, 99 | config: [true, { 'background-repeat': true }], 100 | plugins: ['./index.js'], 101 | accept: [ 102 | { 103 | code: `div { background: url('some-image.jpg') repeat black top center; }`, 104 | description: "Shorthand background property with 'repeat' value.", 105 | }, 106 | { 107 | code: `div { background: url('some-image.jpg') repeat-x black top center; }`, 108 | description: "Shorthand background property with 'repeat-x' value.", 109 | }, 110 | { 111 | code: `div { background: url('some-image.jpg') repeat-y black top center; }`, 112 | description: "Shorthand background property with 'repeat-y' value.", 113 | }, 114 | { 115 | code: `div { background: url('some-image.jpg') no-repeat black top center; }`, 116 | description: "Shorthand background property with 'no-repeat' value.", 117 | }, 118 | { 119 | code: `div { background: url('some-image.jpg') round black top center; }`, 120 | description: "Shorthand background property with 'round' value.", 121 | }, 122 | { 123 | code: `div { background: url('some-image.jpg') space black top center; }`, 124 | description: "Shorthand background property with 'space' value.", 125 | }, 126 | { 127 | code: `div { background: url('some-image.jpg') space round black top center; }`, 128 | description: "Shorthand background property with 'space round' value.", 129 | }, 130 | { 131 | code: `div { background: url('some-image.jpg') black top center; background-repeat: no-repeat; }`, 132 | description: 133 | 'Shorthand background property with background-repeat property.', 134 | }, 135 | { 136 | code: `div { background-image: url('some-image.jpg'); background-repeat: no-repeat; }`, 137 | description: 'Using background-image with background-repeat properties.', 138 | }, 139 | { 140 | code: `div { background-image: linear-gradient(#e66465, #9198e5); }`, 141 | description: 142 | 'Using a linear-gradient background image without background repeat is okay.', 143 | }, 144 | { 145 | code: `div { background-image: linear-gradient(#e66465, #9198e5), url('some-image.jpg'); background-repeat: no-repeat; }`, 146 | description: 147 | 'Using background-image with gradient and url with background-repeat property is okay.', 148 | }, 149 | ], 150 | 151 | reject: [ 152 | { 153 | code: `div { background: url('some-image.jpg') black top center; }`, 154 | description: 'A shorthand background property without a repeat property.', 155 | message: messages.backgroundRepeat(), 156 | }, 157 | { 158 | code: `div { background-image: url('some-image.jpg'); }`, 159 | description: 160 | 'A background-image property without a background-repeat property.', 161 | message: messages.backgroundRepeat(), 162 | }, 163 | { 164 | code: `div { background-image: linear-gradient(#e66465, #9198e5), url('some-image.jpg'); }`, 165 | description: 166 | 'A background-image property with both a gradient and url() but no background-repeat property.', 167 | message: messages.backgroundRepeat(), 168 | }, 169 | ], 170 | }); 171 | 172 | /* eslint-disable-next-line no-undef */ 173 | testRule({ 174 | ruleName, 175 | config: [true, { 'custom-property-fallbacks': true }], 176 | plugins: ['./index.js'], 177 | accept: [ 178 | { 179 | code: `div { color: var(--color-primary, #000); }`, 180 | description: 'A custom property with a fallback color value.', 181 | }, 182 | ], 183 | 184 | reject: [ 185 | { 186 | code: `div { color: var(--color-primary); }`, 187 | description: 'A custom property without a fallback color value.', 188 | message: messages.customPropertyFallbacks(), 189 | }, 190 | { 191 | code: `div { grid-template: var(--page-header-size) 1fr / max-content minmax(0, 1fr) max-content; }`, 192 | description: 193 | 'Using grid template areas without a fallback but a comma elsewhere.', 194 | message: messages.customPropertyFallbacks(), 195 | }, 196 | ], 197 | }); 198 | 199 | /* eslint-disable-next-line no-undef */ 200 | testRule({ 201 | ruleName, 202 | config: [true, { 'custom-property-fallbacks': [true, { ignore: [/hel-/] }] }], 203 | plugins: ['./index.js'], 204 | accept: [ 205 | { 206 | code: `div { color: var(--hel-color-primary); }`, 207 | description: 'A custom property with an ignored namespace.', 208 | }, 209 | ], 210 | 211 | reject: [ 212 | { 213 | code: `div { color: var(--color-primary); }`, 214 | description: 'A custom property without a fallback color value.', 215 | message: messages.customPropertyFallbacks(), 216 | }, 217 | ], 218 | }); 219 | 220 | /* eslint-disable-next-line no-undef */ 221 | testRule({ 222 | ruleName, 223 | config: [ 224 | true, 225 | { 'custom-property-fallbacks': [true, { ignore: [/hel-/, 'mis-'] }] }, 226 | ], 227 | plugins: ['./index.js'], 228 | accept: [ 229 | { 230 | code: `div { color: var(--hel-color-primary); }`, 231 | description: 'A custom property with an ignored namespace.', 232 | }, 233 | { 234 | code: `div { color: var(--mis-color-primary); }`, 235 | description: 'A custom property with an ignored namespace.', 236 | }, 237 | ], 238 | 239 | reject: [ 240 | { 241 | code: `div { color: var(--color-primary); }`, 242 | description: 'A custom property without a fallback color value.', 243 | message: messages.customPropertyFallbacks(), 244 | }, 245 | ], 246 | }); 247 | 248 | /* eslint-disable-next-line no-undef */ 249 | testRule({ 250 | ruleName, 251 | config: [true, { 'flex-wrapping': true }], 252 | plugins: ['./index.js'], 253 | accept: [ 254 | { 255 | code: `div { display: flex; flex-flow: column; }`, 256 | description: 'A container with flex-flow: column defined.', 257 | }, 258 | { 259 | code: `div { display: flex; flex-flow: column-reverse wrap; }`, 260 | description: 'A container with flex-flow: column wrap defined.', 261 | }, 262 | { 263 | code: `div { display: flex; flex-flow: row wrap; }`, 264 | description: 'A container with flex-flow: row wrap defined.', 265 | }, 266 | { 267 | code: `div { display: flex; flex-flow: row-reverse wrap; }`, 268 | description: 'A container with flex-flow: row-reverse wrap defined.', 269 | }, 270 | { 271 | code: `div { display: flex; flex-flow: row nowrap; }`, 272 | description: 'A container with flex-flow: row nowrap defined.', 273 | }, 274 | { 275 | code: `div { display: flex; flex-flow: row-reverse nowrap; }`, 276 | description: 'A container with flex-flow: row-reverse nowrap defined.', 277 | }, 278 | { 279 | code: `div { display: flex; flex-wrap: nowrap; }`, 280 | description: 'A container with flex-wrap: wrap defined.', 281 | }, 282 | { 283 | code: `div { display: flex; flex-wrap: wrap; }`, 284 | description: 'A container with flex-wrap: wrap defined.', 285 | }, 286 | { 287 | code: `div { display: flex; flex-wrap: wrap-reverse; }`, 288 | description: 'A container with flex-wrap: wrap defined.', 289 | }, 290 | { 291 | code: `div { display: flex; flex-direction: column; }`, 292 | description: 'Ignores flex column.', 293 | }, 294 | { 295 | code: `div { display: flex; flex-direction: column-reverse; }`, 296 | description: 'Ignores flex column-reverse.', 297 | }, 298 | { 299 | code: `div { display: flex; flex-direction: row; flex-wrap: wrap; }`, 300 | description: 'Ignores flex direction row.', 301 | }, 302 | { 303 | code: `div { display: flex; flex-direction: row-reverse; flex-wrap: wrap-reverse; }`, 304 | description: 'Ignores flex direction row-reverse.', 305 | }, 306 | { 307 | code: `div { display: inline-flex; flex-direction: column; }`, 308 | description: 'Allows inline flex with direction column.', 309 | }, 310 | ], 311 | 312 | reject: [ 313 | { 314 | code: `div { display: flex; flex-flow: row; }`, 315 | description: 'A flex flow container without a wrap property defined.', 316 | message: messages.flexWrapping(), 317 | }, 318 | { 319 | code: `div { display: flex; flex-flow: row-reverse; }`, 320 | description: 'A flex flow container without a wrap property defined.', 321 | message: messages.flexWrapping(), 322 | }, 323 | { 324 | code: `div { display: flex; }`, 325 | description: 'A flex container without a flex-wrap property defined.', 326 | message: messages.flexWrapping(), 327 | }, 328 | { 329 | code: `div { display: inline-flex; }`, 330 | description: 'A flex container without a flex-wrap property defined.', 331 | message: messages.flexWrapping(), 332 | }, 333 | { 334 | code: `div { display: flex; flex-direction: row; }`, 335 | description: 336 | 'A flex container set to direction row but without a flex-wrap property defined.', 337 | message: messages.flexWrapping(), 338 | }, 339 | ], 340 | }); 341 | 342 | /* eslint-disable-next-line no-undef */ 343 | testRule({ 344 | ruleName, 345 | config: [true, { 'scrollbar-gutter': true }], 346 | plugins: ['./index.js'], 347 | accept: [ 348 | { 349 | code: `div { overflow: auto; scrollbar-gutter: auto; }`, 350 | description: 'A container with shorthand overflow auto property.', 351 | }, 352 | { 353 | code: `div { overflow: hidden; }`, 354 | description: 'A container with shorthand overflow hidden property.', 355 | }, 356 | { 357 | code: `div { overflow: scroll; scrollbar-gutter: stable; }`, 358 | description: 'A container with shorthand overflow scroll property.', 359 | }, 360 | 361 | { 362 | code: `div { overflow: auto hidden; scrollbar-gutter: stable both-edges; }`, 363 | description: 'A container with shorthand overflow auto hidden property.', 364 | }, 365 | { 366 | code: `div { overflow-x: hidden; }`, 367 | description: 'A container with overflow-x hidden property.', 368 | }, 369 | { 370 | code: `div { overflow-x: auto; scrollbar-gutter: stable; }`, 371 | description: 'A container with overflow-x auto property.', 372 | }, 373 | { 374 | code: `div { overflow-x: auto; overflow-y: scroll; scrollbar-gutter: stable; }`, 375 | description: 376 | 'A container with overflow-x auto and overflow-y scroll property.', 377 | }, 378 | { 379 | code: `div { overflow-block: auto; scrollbar-gutter: stable; }`, 380 | description: 'A container with overflow-block auto property.', 381 | }, 382 | { 383 | code: `div { overflow-inline: hidden; }`, 384 | description: 'A container with overflow-inline hidden property.', 385 | }, 386 | { 387 | code: `div { overflow-anchor: auto; }`, 388 | description: 389 | 'A container with overflow-anchor property which should be ignored.', 390 | }, 391 | ], 392 | 393 | reject: [ 394 | { 395 | code: `div { overflow: auto; }`, 396 | description: 'A container with shorthand overflow auto property.', 397 | message: messages.scrollbarGutter(), 398 | }, 399 | { 400 | code: `div { overflow: auto hidden; }`, 401 | description: 'A container with shorthand overflow auto hidden property.', 402 | message: messages.scrollbarGutter(), 403 | }, 404 | { 405 | code: `div { overflow-x: auto; }`, 406 | description: 'A container with overflow-x auto property.', 407 | message: messages.scrollbarGutter(), 408 | }, 409 | { 410 | code: `div { overflow-y: scroll; }`, 411 | description: 'A container with overflow-y scroll property.', 412 | message: messages.scrollbarGutter(), 413 | }, 414 | { 415 | code: `div { overflow-y: scroll; overflow-x: auto; }`, 416 | description: 417 | 'A container with overflow-y scroll and overflow-x auto property.', 418 | message: messages.scrollbarGutter(), 419 | }, 420 | { 421 | code: `div { overflow-block: scroll; overflow-inline: auto; }`, 422 | description: 423 | 'A container with overflow-block scroll and overflow-inline auto property.', 424 | message: messages.scrollbarGutter(), 425 | }, 426 | ], 427 | }); 428 | 429 | /* eslint-disable-next-line no-undef */ 430 | testRule({ 431 | ruleName, 432 | config: [true, { 'scroll-chaining': true }], 433 | plugins: ['./index.js'], 434 | accept: [ 435 | { 436 | code: `div { overflow: auto; overscroll-behavior: contain; }`, 437 | description: 'A container with shorthand overflow auto property.', 438 | }, 439 | { 440 | code: `div { overflow: hidden; }`, 441 | description: 'A container with shorthand overflow hidden property.', 442 | }, 443 | { 444 | code: `div { overflow: scroll; overscroll-behavior: contain; }`, 445 | description: 'A container with shorthand overflow scroll property.', 446 | }, 447 | { 448 | code: `div { overflow: auto hidden; overscroll-behavior: contain; }`, 449 | description: 'A container with shorthand overflow auto hidden property.', 450 | }, 451 | { 452 | code: `div { overflow-x: hidden; }`, 453 | description: 'A container with overflow-x hidden property.', 454 | }, 455 | { 456 | code: `div { overflow-x: auto; overscroll-behavior: contain; }`, 457 | description: 'A container with overflow-x auto property.', 458 | }, 459 | { 460 | code: `div { overflow-x: auto; overflow-y: scroll; overscroll-behavior: contain; }`, 461 | description: 462 | 'A container with overflow-x auto and overflow-y scroll property.', 463 | }, 464 | { 465 | code: `div { overflow-block: auto; overscroll-behavior: contain; }`, 466 | description: 'A container with overflow-block auto property.', 467 | }, 468 | { 469 | code: `div { overflow-inline: hidden; }`, 470 | description: 'A container with overflow-inline hidden property.', 471 | }, 472 | { 473 | code: `div { overflow-anchor: auto; }`, 474 | description: 475 | 'A container with overflow-anchor property which should be ignored.', 476 | }, 477 | ], 478 | 479 | reject: [ 480 | { 481 | code: `div { overflow: auto; }`, 482 | description: 'A container with shorthand overflow auto property.', 483 | message: messages.scrollChaining(), 484 | }, 485 | { 486 | code: `div { overflow: auto hidden; }`, 487 | description: 'A container with shorthand overflow auto hidden property.', 488 | message: messages.scrollChaining(), 489 | }, 490 | { 491 | code: `div { overflow-x: auto; }`, 492 | description: 'A container with overflow-x auto property.', 493 | message: messages.scrollChaining(), 494 | }, 495 | { 496 | code: `div { overflow-y: scroll; }`, 497 | description: 'A container with overflow-y scroll property.', 498 | message: messages.scrollChaining(), 499 | }, 500 | { 501 | code: `div { overflow-y: scroll; overflow-x: auto; }`, 502 | description: 503 | 'A container with overflow-y scroll and overflow-x auto property.', 504 | message: messages.scrollChaining(), 505 | }, 506 | { 507 | code: `div { overflow-block: scroll; overflow-inline: auto; }`, 508 | description: 509 | 'A container with overflow-block scroll and overflow-inline auto property.', 510 | message: messages.scrollChaining(), 511 | }, 512 | ], 513 | }); 514 | 515 | /* eslint-disable-next-line no-undef */ 516 | testRule({ 517 | ruleName, 518 | config: [true, { 'vendor-prefix-grouping': true }], 519 | plugins: ['./index.js'], 520 | accept: [ 521 | { 522 | code: `.menu-item { 523 | &.menu-item-has-children::after, 524 | &.menu-item-has-grandchildren::after { 525 | content: ''; 526 | position: absolute; 527 | top: 24px; 528 | right: 30px; 529 | width: 18px; 530 | height: 18px; 531 | background-size: contain; 532 | } 533 | }`, 534 | description: 'Nested scss with no prefixes defined.', 535 | }, 536 | { 537 | code: `input::-webkit-input-placeholder { color: #222; } input::-moz-placeholder { color: #222; }`, 538 | description: 539 | 'Split webkit and moz placeholder selectors to separate rules.', 540 | }, 541 | { 542 | code: `input::-ms-input-placeholder { color: #222; } input::-o-placeholder { color: #222; }`, 543 | description: 544 | 'Split webkit and moz placeholder selectors to separate rules.', 545 | }, 546 | { 547 | code: `div::before,div::after { color: #222; }`, 548 | description: 549 | 'Combining pseudo elements with the same selector into one rule.', 550 | }, 551 | { 552 | code: `.a video::-webkit-media-controls-panel 553 | .b video::-webkit-media-controls-panel { 554 | display: none; 555 | }`, 556 | description: 557 | 'Combine two of the same vendor prefixes into the same selector.', 558 | }, 559 | { 560 | code: ` 561 | .tabs-pink--active strong, 562 | .tabs-pink--active svg, 563 | .tabs-pink:hover strong, 564 | .tab-type-2--active strong, 565 | .tab-type-2--active svg, 566 | .tab-type-2:hover strong, 567 | .tab-resource-item:hover { 568 | color: #e20072; 569 | }`, 570 | description: 571 | 'Combining a bunch of selectors into one rule. See: https://github.com/yuschick/stylelint-plugin-defensive-css/issues/4', 572 | }, 573 | ], 574 | 575 | reject: [ 576 | { 577 | code: `input::-webkit-input-placeholder, input::-moz-placeholder { color: #222; }`, 578 | description: 'Using webkit and moz placeholder selectors.', 579 | message: messages.vendorPrefixWGrouping(), 580 | }, 581 | { 582 | code: `input::-ms-input-placeholder, input::-o-placeholder { color: #222; }`, 583 | description: 'Using webkit and moz placeholder selectors.', 584 | message: messages.vendorPrefixWGrouping(), 585 | }, 586 | ], 587 | }); 588 | -------------------------------------------------------------------------------- /src/utils/findCustomProperties.js: -------------------------------------------------------------------------------- 1 | const expression = /var\(.+?\)/g; 2 | 3 | export function findCustomProperties(value) { 4 | if (!value) return false; 5 | 6 | let propertiesFound = [...value.trim().matchAll(expression)]; 7 | return propertiesFound 8 | .map(([property]) => (property.includes(',') ? undefined : property)) 9 | .filter((value) => value); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/findShorthandBackgroundRepeat.js: -------------------------------------------------------------------------------- 1 | const expression = /\b(repeat|repeat-x|repeat-y|space|round|no-repeat|)\b/g; 2 | 3 | export function findShorthandBackgroundRepeat(value) { 4 | return value.match(expression).some((val) => val); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/findVendorPrefixes.js: -------------------------------------------------------------------------------- 1 | const expression = /-\b(moz|ms|o|webkit)\b-/g; 2 | 3 | export function findVendorPrefixes(selector) { 4 | if (!selector) return false; 5 | 6 | const prefixesFound = [...selector.trim().matchAll(expression)]; 7 | const prefixes = new Set(prefixesFound.map((prefix) => prefix[1])); 8 | 9 | return prefixes.size > 1; 10 | } 11 | --------------------------------------------------------------------------------