Skip to content

Commit

Permalink
Add media-feature-name-unit-allowed-list (#6550)
Browse files Browse the repository at this point in the history
* First pass: `media-feature-name-unit-allowed-list`

Adds base rule, refactors slightly from @romainmenke's suggestion;
adds docs, rudimentary tests.

Co-authored-by: Romain Menke <11521496+romainmenke@users.noreply.github.com>

* Fix end positions

Co-authored-by: Romain Menke <11521496+romainmenke@users.noreply.github.com>

* add tests for feature names with no attached unit

* Properly grabs `name` for range queries

Required a bit of hardcoding?

* add changeset

* Uses `entry.node.name.getName()`

Co-authored-by: Romain Menke <11521496+romainmenke@users.noreply.github.com>

* Update changeset to match naming convention

* Update changeset to match naming convention (again)

Co-authored-by: Marc G. <Mouvedia@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>

* misc standardization

* fix postcss location

* Improve readability/conciseness of library code

No behaviour changes.

Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>

Co-authored-by: Romain Menke <11521496+romainmenke@users.noreply.github.com>
Co-authored-by: Marc G. <Mouvedia@users.noreply.github.com>
Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
4 people committed Jan 5, 2023
1 parent f5b27ca commit 00de033
Show file tree
Hide file tree
Showing 8 changed files with 416 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-monkeys-fold.md
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: `media-feature-name-unit-allowed-list` rule
1 change: 1 addition & 0 deletions docs/user-guide/rules.md
Expand Up @@ -136,6 +136,7 @@ Allow, disallow or require things with these `allowed-list`, `disallowed-list`,
- [`media-feature-name-allowed-list`](../../lib/rules/media-feature-name-allowed-list/README.md): Specify a list of allowed media feature names.
- [`media-feature-name-disallowed-list`](../../lib/rules/media-feature-name-disallowed-list/README.md): Specify a list of disallowed media feature names.
- [`media-feature-name-no-vendor-prefix`](../../lib/rules/media-feature-name-no-vendor-prefix/README.md): Disallow vendor prefixes for media feature names (Autofixable).
- [`media-feature-name-unit-allowed-list`](../../lib/rules/media-feature-name-unit-allowed-list/README.md): Specify a list of allowed name and unit pairs within media features.
- [`media-feature-name-value-allowed-list`](../../lib/rules/media-feature-name-value-allowed-list/README.md): Specify a list of allowed media feature name and value pairs.

#### Property
Expand Down
3 changes: 3 additions & 0 deletions lib/rules/index.js
Expand Up @@ -191,6 +191,9 @@ const rules = {
'media-feature-name-no-vendor-prefix': importLazy(() =>
require('./media-feature-name-no-vendor-prefix'),
)(),
'media-feature-name-unit-allowed-list': importLazy(() =>
require('./media-feature-name-unit-allowed-list'),
)(),
'media-feature-name-value-allowed-list': importLazy(() =>
require('./media-feature-name-value-allowed-list'),
)(),
Expand Down
69 changes: 69 additions & 0 deletions lib/rules/media-feature-name-unit-allowed-list/README.md
@@ -0,0 +1,69 @@
# media-feature-name-unit-allowed-list

Specify a list of allowed name and unit pairs within media features.

<!-- prettier-ignore -->
```css
@media (width: 50em) {}
/** ↑ ↑
* This media feature name and these units */
```

## Options

`object`: `{ "name": ["array", "of", "units"]|"unit" }`

If a feature name is surrounded with `"/"` (e.g. `"/height/"`), it is interpreted as a regular expression. This allows, for example, easy targeting of shorthands: `/height/` will match `height`, `min-height`, `max-height`, etc.

Given:

```json
{
"width": "em",
"/height/": ["em", "rem"]
}
```

The following patterns are considered problems:

<!-- prettier-ignore -->
```css
@media (width: 50rem) {}
```

<!-- prettier-ignore -->
```css
@media (height: 1000px) {}
```

<!-- prettier-ignore -->
```css
@media (min-height: 1000px) {}
```

<!-- prettier-ignore -->
```css
@media (height <= 1000px) {}
```

The following patterns are _not_ considered problems:

<!-- prettier-ignore -->
```css
@media (width: 50em) {}
```

<!-- prettier-ignore -->
```css
@media (width <= 50em) {}
```

<!-- prettier-ignore -->
```css
@media (height: 50em) {}
```

<!-- prettier-ignore -->
```css
@media (min-height: 50rem) {}
```
170 changes: 170 additions & 0 deletions lib/rules/media-feature-name-unit-allowed-list/__tests__/index.js
@@ -0,0 +1,170 @@
'use strict';

const { messages, ruleName } = require('..');

testRule({
ruleName,
config: { width: 'em', '/height/': ['em', 'rem'] },

accept: [
{
code: '@media (width: 50em) {}',
},
{
code: '@media (width <= 50em) {}',
description: 'media queries level 4 - inequality',
},
{
code: '@media (min-width: 50rem) {}',
},
{
code: '@media (max-width: 50rem) {}',
},
{
code: '@media (30em <= width <= 50em) {}',
description: 'media queries level 4 - range',
},
{
code: '@media print and (width: 50em) {}',
description: 'compound selector',
},
{
code: '@media (height: 50rem) {}',
},
{
code: '@media (min-height: 50rem) {}',
},
{
code: '@media (max-height: 50rem) {}',
},
{
code: '@media (30rem <= height <= 50rem) {}',
description: 'media queries level 4 - range',
},
],

reject: [
{
code: '@media (width: 50rem) {}',
message: messages.rejected('rem', 'width'),
line: 1,
column: 16,
endLine: 1,
endColumn: 21,
},
{
code: '@media (width <= 50rem) {}',
message: messages.rejected('rem', 'width'),
description: 'media queries level 4 - inequality',
line: 1,
column: 18,
endLine: 1,
endColumn: 23,
},
{
code: '@media (30rem <= width <= 50rem) {}',
description: 'media queries level 4 - range',
warnings: [
{
message: messages.rejected('rem', 'width'),
line: 1,
column: 9,
endLine: 1,
endColumn: 14,
},
{
message: messages.rejected('rem', 'width'),
line: 1,
column: 27,
endLine: 1,
endColumn: 32,
},
],
},
{
code: '@media (width: 1000px) {}',
message: messages.rejected('px', 'width'),
line: 1,
column: 16,
endLine: 1,
endColumn: 22,
},
{
code: '@media (height: 1000px) {}',
message: messages.rejected('px', 'height'),
line: 1,
column: 17,
endLine: 1,
endColumn: 23,
},
{
code: '@media (height <= 1000px) {}',
message: messages.rejected('px', 'height'),
description: 'media queries level 4 - inequality',
line: 1,
column: 19,
endLine: 1,
endColumn: 25,
},
{
code: '@media (100px <= height <= 1000px) {}',
description: 'media queries level 4 - range',
warnings: [
{
message: messages.rejected('px', 'height'),
line: 1,
column: 9,
endLine: 1,
endColumn: 14,
},
{
message: messages.rejected('px', 'height'),
line: 1,
column: 28,
endLine: 1,
endColumn: 34,
},
],
},
{
code: '@media (min-height: 1000px) {}',
message: messages.rejected('px', 'min-height'),
line: 1,
column: 21,
endLine: 1,
endColumn: 27,
},
{
code: '@media (max-height: 1000px) {}',
message: messages.rejected('px', 'max-height'),
line: 1,
column: 21,
endLine: 1,
endColumn: 27,
},
],
});

testRule({
ruleName,
config: { grid: [], 'prefers-reduced-motion': [], 'video-dynamic-range': [] },

accept: [
{
code: '@media (prefers-reduced-motion) {}',
description: 'name-only value',
},
{
code: '@media (color) {}',
description: 'name-only value',
},
{
code: '@media (grid: 0) {}',
description: 'unitless value, number',
},
{
code: '@media (video-dynamic-range: high) {}',
description: 'unitless value, keyword',
},
],
});
102 changes: 102 additions & 0 deletions lib/rules/media-feature-name-unit-allowed-list/index.js
@@ -0,0 +1,102 @@
'use strict';

const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const validateOptions = require('../../utils/validateOptions');
const { TokenType } = require('@csstools/css-tokenizer');
const { isTokenNode } = require('@csstools/css-parser-algorithms');
const {
isMediaFeaturePlain,
isMediaFeatureRange,
parse,
} = require('@csstools/media-query-list-parser');
const validateObjectWithArrayProps = require('../../utils/validateObjectWithArrayProps');
const { isString } = require('../../utils/validateTypes');
const matchesStringOrRegExp = require('../../utils/matchesStringOrRegExp');
const atRuleParamIndex = require('../../utils/atRuleParamIndex');

const ruleName = 'media-feature-name-unit-allowed-list';

const messages = ruleMessages(ruleName, {
rejected: (unit, name) => `Unexpected unit "${unit}" for name "${name}"`,
});

const meta = {
url: 'https://stylelint.io/user-guide/rules/media-feature-name-unit-allowed-list',
};

/** @type {import('stylelint').Rule<Record<string, string | string[]>>} */
const rule = (primary) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: [validateObjectWithArrayProps(isString)],
});

if (!validOptions) {
return;
}

const primaryPairs = Object.entries(primary);
const primaryUnitList = (/** @type {string} */ featureName) => {
for (const [name, unit] of primaryPairs) {
if (matchesStringOrRegExp(featureName, name)) return [unit].flat();
}

return undefined;
};

root.walkAtRules(/^media$/i, (atRule) => {
const mediaQueryList = parse(atRule.params);

mediaQueryList.forEach((mediaQuery) => {
mediaQuery.walk((entry) => {
if (!isMediaFeaturePlain(entry.node) && !isMediaFeatureRange(entry.node)) {
return;
}

const featureName = entry.node.name.getName();
const unitList = primaryUnitList(featureName);

if (!unitList) {
return;
}

entry.node.walk(({ node: childNode }) => {
if (!isTokenNode(childNode)) {
return;
}

const [tokenType, , startIndex, endIndex] = childNode.value;

if (tokenType !== TokenType.Dimension) {
return;
}

const unit = childNode.value[4].unit;

if (unitList.includes(unit.toLowerCase())) {
return;
}

const atRuleIndex = atRuleParamIndex(atRule);

report({
message: messages.rejected(unit, featureName),
node: atRule,
index: atRuleIndex + startIndex,
endIndex: atRuleIndex + endIndex + 1,
result,
ruleName,
});
});
});
});
});
};
};

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
module.exports = rule;

0 comments on commit 00de033

Please sign in to comment.