Skip to content

Commit 3bc226a

Browse files
New rule: check the new color CSS vars have a fallback (#122)
* testing new rule to flag new css without fallback * added css vars to jsomn * rename, add index, add docs, remove scale * lint * lint * lint... * fix import * more lint * format * Create wet-lies-visit.md * fix name * oops --------- Co-authored-by: langermank <[email protected]>
1 parent 7f4c467 commit 3bc226a

File tree

7 files changed

+476
-0
lines changed

7 files changed

+476
-0
lines changed

.changeset/wet-lies-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-primer-react": patch
3+
---
4+
5+
New rule: new-color-css-vars-have-fallback: checks that if a new color var is used, it has a fallback value
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Ensure new Primitive v8 color CSS vars have a fallback
2+
3+
This rule is temporary as we begin testing v8 color tokens behind a feature flag. If a color token is used without a fallback, the color will only render if the feature flag is enabled. This rule is an extra safety net to ensure we don't accidentally ship code that relies on the feature flag.
4+
5+
## Rule Details
6+
7+
This rule refers to a JSON file that lists all the new color tokens
8+
9+
```json
10+
["--fgColor-default", "--fgColor-muted", "--fgColor-onEmphasis"]
11+
```
12+
13+
If it finds that one of these tokens is used without a fallback, it will throw an error.
14+
15+
👎 Examples of **incorrect** code for this rule
16+
17+
```jsx
18+
<Button sx={{color: 'var(--fgColor-muted)'}}>Test</Button>
19+
```
20+
21+
👍 Examples of **correct** code for this rule:
22+
23+
```jsx
24+
<Button sx={{color: 'var(--fgColor-muted, var(--color-fg-muted))'}}>Test</Button>
25+
```

src/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
'primer-react/a11y-tooltip-interactive-trigger': 'error',
1717
'primer-react/new-color-css-vars': 'error',
1818
'primer-react/a11y-explicit-heading': 'error',
19+
'primer-react/new-color-css-vars-have-fallback': 'error',
1920
},
2021
settings: {
2122
github: {

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = {
66
'a11y-tooltip-interactive-trigger': require('./rules/a11y-tooltip-interactive-trigger'),
77
'new-color-css-vars': require('./rules/new-color-css-vars'),
88
'a11y-explicit-heading': require('./rules/a11y-explicit-heading'),
9+
'new-color-css-vars-have-fallback': require('./rules/new-color-css-vars-have-fallback'),
910
},
1011
configs: {
1112
recommended: require('./configs/recommended'),
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const rule = require('../new-color-css-vars-have-fallback')
2+
const {RuleTester} = require('eslint')
3+
4+
const ruleTester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 'latest',
7+
sourceType: 'module',
8+
ecmaFeatures: {
9+
jsx: true,
10+
},
11+
},
12+
})
13+
14+
ruleTester.run('new-color-css-vars-have-fallback', rule, {
15+
valid: [
16+
{
17+
code: `<circle stroke="var(--fgColor-muted, var(--color-fg-muted))" strokeWidth="2" />`,
18+
},
19+
],
20+
invalid: [
21+
{
22+
code: `<circle stroke="var(--fgColor-muted)" strokeWidth="2" />`,
23+
errors: [
24+
{
25+
message:
26+
'Expected a fallback value for CSS variable --fgColor-muted. New color variables fallbacks, check primer.style/primitives to find the correct value.',
27+
},
28+
],
29+
},
30+
],
31+
})
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const cssVars = require('../utils/new-color-css-vars-map')
2+
3+
const reportError = (propertyName, valueNode, context) => {
4+
// performance optimisation: exit early
5+
if (valueNode.type !== 'Literal' && valueNode.type !== 'TemplateElement') return
6+
// get property value
7+
const value = valueNode.type === 'Literal' ? valueNode.value : valueNode.value.cooked
8+
// return if value is not a string
9+
if (typeof value !== 'string') return
10+
// return if value does not include variable
11+
if (!value.includes('var(')) return
12+
13+
const varRegex = /var\([^(),)]+\)/g
14+
15+
const match = value.match(varRegex)
16+
// return if no matches
17+
if (!match) return
18+
const vars = match.flatMap(match =>
19+
match
20+
.slice(4, -1)
21+
.trim()
22+
.split(/\s*,\s*/g),
23+
)
24+
for (const cssVar of vars) {
25+
// return if no repalcement exists
26+
if (!cssVars?.includes(cssVar)) return
27+
// report the error
28+
context.report({
29+
node: valueNode,
30+
message: `Expected a fallback value for CSS variable ${cssVar}. New color variables fallbacks, check primer.style/primitives to find the correct value.`,
31+
})
32+
}
33+
}
34+
35+
const reportOnObject = (node, context) => {
36+
const propertyName = node.key.name
37+
if (node.value?.type === 'Literal') {
38+
reportError(propertyName, node.value, context)
39+
} else if (node.value?.type === 'ConditionalExpression') {
40+
reportError(propertyName, node.value.consequent, context)
41+
reportError(propertyName, node.value.alternate, context)
42+
}
43+
}
44+
45+
const reportOnProperty = (node, context) => {
46+
const propertyName = node.name.name
47+
if (node.value?.type === 'Literal') {
48+
reportError(propertyName, node.value, context)
49+
} else if (node.value?.type === 'JSXExpressionContainer' && node.value.expression?.type === 'ConditionalExpression') {
50+
reportError(propertyName, node.value.expression.consequent, context)
51+
reportError(propertyName, node.value.expression.alternate, context)
52+
}
53+
}
54+
55+
const reportOnValue = (node, context) => {
56+
if (node?.type === 'Literal') {
57+
reportError(undefined, node, context)
58+
} else if (node?.type === 'JSXExpressionContainer' && node.expression?.type === 'ConditionalExpression') {
59+
reportError(undefined, node.value.expression.consequent, context)
60+
reportError(undefined, node.value.expression.alternate, context)
61+
}
62+
}
63+
64+
const reportOnTemplateElement = (node, context) => {
65+
reportError(undefined, node, context)
66+
}
67+
68+
module.exports = {
69+
meta: {
70+
type: 'suggestion',
71+
},
72+
/** @param {import('eslint').Rule.RuleContext} context */
73+
create(context) {
74+
return {
75+
// sx OR style property on elements
76+
['JSXAttribute:matches([name.name=sx], [name.name=style]) ObjectExpression Property']: node =>
77+
reportOnObject(node, context),
78+
// property on element like stroke or fill
79+
['JSXAttribute[name.name!=sx][name.name!=style]']: node => reportOnProperty(node, context),
80+
// variable that is a value
81+
[':matches(VariableDeclarator, ReturnStatement) > Literal']: node => reportOnValue(node, context),
82+
// variable that is a value
83+
[':matches(VariableDeclarator, ReturnStatement) > TemplateElement']: node =>
84+
reportOnTemplateElement(node, context),
85+
}
86+
},
87+
}

0 commit comments

Comments
 (0)