Skip to content

Commit 35f0ffe

Browse files
authored
Merge pull request #81 from primer/new-css-vars
New rule: `new-color-css-vars`
2 parents 0cb5f8c + 821ef4d commit 35f0ffe

File tree

8 files changed

+3067
-530
lines changed

8 files changed

+3067
-530
lines changed

.changeset/early-ads-clap.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` to find/replace legacy CSS color vars in sx prop

package-lock.json

Lines changed: 754 additions & 527 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@
2424
"@changesets/changelog-github": "^0.4.0",
2525
"@changesets/cli": "^2.16.0",
2626
"@github/prettier-config": "0.0.4",
27-
"@primer/primitives": "^7.11.14",
28-
"eslint": "^8.0.1",
27+
"@primer/primitives": "^7.14.0",
28+
"eslint": "^8.42.0",
2929
"jest": "^27.0.6"
3030
},
3131
"peerDependencies": {
3232
"@primer/primitives": ">=4.6.2",
33-
"eslint": "^8.0.1"
33+
"eslint": "^8.42.0"
3434
},
3535
"prettier": "@github/prettier-config",
3636
"dependencies": {

src/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
'primer-react/no-deprecated-colors': 'warn',
1515
'primer-react/no-system-props': 'warn',
1616
'primer-react/a11y-tooltip-interactive-trigger': 'error',
17+
'primer-react/new-color-css-vars': 'error',
1718
'primer-react/a11y-explicit-heading': 'error'
1819
},
1920
settings: {

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = {
44
'no-deprecated-colors': require('./rules/no-deprecated-colors'),
55
'no-system-props': require('./rules/no-system-props'),
66
'a11y-tooltip-interactive-trigger': require('./rules/a11y-tooltip-interactive-trigger'),
7+
'new-color-css-vars': require('./rules/new-color-css-vars'),
78
'a11y-explicit-heading': require('./rules/a11y-explicit-heading')
89
},
910
configs: {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const rule = require('../new-color-css-vars')
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('no-color-css-vars', rule, {
15+
valid: [
16+
{
17+
code: `{color: 'fg.default'}`
18+
},
19+
{
20+
code: `<circle stroke="var(--color-border-default)" strokeWidth="2" />`
21+
},
22+
{
23+
code: `<circle fill="var(--color-border-default)" strokeWidth="2" />`
24+
},
25+
{
26+
code: `<div style={{ color: 'var(--color-border-default)' }}></div>`
27+
},
28+
{
29+
code: `<Blankslate border></Blankslate>`
30+
}
31+
],
32+
invalid: [
33+
{
34+
code: `<Button sx={{color: 'var(--color-fg-muted)'}}>Test</Button>`,
35+
output: `<Button sx={{color: 'var(--fgColor-muted, var(--color-fg-muted))'}}>Test</Button>`,
36+
errors: [
37+
{
38+
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
39+
}
40+
]
41+
},
42+
{
43+
code: `
44+
<Box sx={{
45+
'&:hover [data-component="copy-link"] button, &:focus [data-component="copy-link"] button': {
46+
color: 'var(--color-accent-fg)'
47+
}
48+
}}>
49+
</Box>`,
50+
output: `
51+
<Box sx={{
52+
'&:hover [data-component="copy-link"] button, &:focus [data-component="copy-link"] button': {
53+
color: 'var(--fgColor-accent, var(--color-accent-fg))'
54+
}
55+
}}>
56+
</Box>`,
57+
errors: [
58+
{
59+
message: 'Replace var(--color-accent-fg) with var(--fgColor-accent, var(--color-accent-fg))'
60+
}
61+
]
62+
},
63+
{
64+
code: `<Box sx={{boxShadow: '0 0 0 2px var(--color-canvas-subtle)'}} />`,
65+
output: `<Box sx={{boxShadow: '0 0 0 2px var(--bgColor-muted, var(--color-canvas-subtle))'}} />`,
66+
errors: [
67+
{
68+
message: 'Replace var(--color-canvas-subtle) with var(--bgColor-muted, var(--color-canvas-subtle))'
69+
}
70+
]
71+
},
72+
{
73+
code: `<Box sx={{border: 'solid 2px var(--color-border-default)'}} />`,
74+
output: `<Box sx={{border: 'solid 2px var(--borderColor-default, var(--color-border-default))'}} />`,
75+
errors: [
76+
{
77+
message: 'Replace var(--color-border-default) with var(--borderColor-default, var(--color-border-default))'
78+
}
79+
]
80+
},
81+
{
82+
code: `<Box sx={{backgroundColor: 'var(--color-canvas-default)'}} />`,
83+
output: `<Box sx={{backgroundColor: 'var(--bgColor-default, var(--color-canvas-default))'}} />`,
84+
errors: [
85+
{
86+
message: 'Replace var(--color-canvas-default) with var(--bgColor-default, var(--color-canvas-default))'
87+
}
88+
]
89+
},
90+
{
91+
name: 'variable in scope',
92+
code: `
93+
const baseStyles = { color: 'var(--color-fg-muted)' }
94+
export const Fixture = <Button sx={baseStyles}>Test</Button>
95+
`,
96+
output: `
97+
const baseStyles = { color: 'var(--fgColor-muted, var(--color-fg-muted))' }
98+
export const Fixture = <Button sx={baseStyles}>Test</Button>
99+
`,
100+
errors: [
101+
{
102+
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
103+
}
104+
]
105+
},
106+
{
107+
name: 'merge in sx',
108+
code: `
109+
import {merge} from '@primer/react'
110+
export const Fixture = props => <Button sx={merge({color: 'var(--color-fg-muted)'}, props.sx)}>Test</Button>
111+
`,
112+
output: `
113+
import {merge} from '@primer/react'
114+
export const Fixture = props => <Button sx={merge({color: 'var(--fgColor-muted, var(--color-fg-muted))'}, props.sx)}>Test</Button>
115+
`,
116+
errors: [
117+
{
118+
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
119+
}
120+
]
121+
},
122+
{
123+
code: `<Box sx={{borderColor: 'var(--color-border-default)'}} />`,
124+
output: `<Box sx={{borderColor: 'var(--borderColor-default, var(--color-border-default))'}} />`,
125+
errors: [
126+
{
127+
message: 'Replace var(--color-border-default) with var(--borderColor-default, var(--color-border-default))'
128+
}
129+
]
130+
}
131+
]
132+
})

src/rules/new-color-css-vars.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
const cssVars = require('../utils/css-variable-map.json')
2+
3+
module.exports = {
4+
meta: {
5+
type: 'suggestion',
6+
hasSuggestions: true,
7+
fixable: 'code',
8+
docs: {
9+
description: 'Upgrade legacy CSS variables to Primitives v8 in sx prop'
10+
},
11+
schema: [
12+
{
13+
type: 'object',
14+
properties: {
15+
skipImportCheck: {
16+
type: 'boolean'
17+
},
18+
checkAllStrings: {
19+
type: 'boolean'
20+
}
21+
},
22+
additionalProperties: false
23+
}
24+
]
25+
},
26+
/** @param {import('eslint').Rule.RuleContext} context */
27+
create(context) {
28+
const styledSystemProps = [
29+
'bg',
30+
'backgroundColor',
31+
'color',
32+
'borderColor',
33+
'borderTopColor',
34+
'borderRightColor',
35+
'borderBottomColor',
36+
'borderLeftColor',
37+
'border',
38+
'boxShadow',
39+
'caretColor'
40+
]
41+
42+
return {
43+
/** @param {import('eslint').Rule.Node} node */
44+
JSXAttribute(node) {
45+
if (node.name.name === 'sx') {
46+
if (node.value.expression.type === 'ObjectExpression') {
47+
// example: sx={{ color: 'fg.default' }} or sx={{ ':hover': {color: 'fg.default'} }}
48+
const rawText = context.sourceCode.getText(node.value)
49+
checkForVariables(node.value, rawText)
50+
} else if (node.value.expression.type === 'Identifier') {
51+
// example: sx={baseStyles}
52+
const variableScope = context.sourceCode.getScope(node.value.expression)
53+
const variable = variableScope.set.get(node.value.expression.name)
54+
55+
// if variable is not defined in scope, give up (could be imported from different file)
56+
if (!variable) return
57+
58+
const variableDeclarator = variable.identifiers[0].parent
59+
const rawText = context.sourceCode.getText(variableDeclarator)
60+
checkForVariables(variableDeclarator, rawText)
61+
} else {
62+
// worth a try!
63+
const rawText = context.sourceCode.getText(node.value)
64+
checkForVariables(node.value, rawText)
65+
}
66+
} else if (
67+
styledSystemProps.includes(node.name.name) &&
68+
node.value &&
69+
node.value.type === 'Literal' &&
70+
typeof node.value.value === 'string'
71+
) {
72+
checkForVariables(node.value, node.value.value)
73+
}
74+
}
75+
}
76+
77+
function checkForVariables(node, rawText) {
78+
// performance optimisation: exit early
79+
if (!rawText.includes('var')) return
80+
81+
Object.keys(cssVars).forEach(cssVar => {
82+
if (Array.isArray(cssVars[cssVar])) {
83+
cssVars[cssVar].forEach(cssVarObject => {
84+
const regex = new RegExp(`var\\(${cssVar}\\)`, 'g')
85+
if (
86+
cssVarObject.props.some(prop => rawText.includes(prop)) &&
87+
regex.test(rawText) &&
88+
!rawText.includes(cssVarObject.replacement)
89+
) {
90+
const fixedString = rawText.replace(regex, `var(${cssVarObject.replacement}, var(${cssVar}))`)
91+
if (!rawText.includes(fixedString)) {
92+
context.report({
93+
node,
94+
message: `Replace var(${cssVar}) with var(${cssVarObject.replacement}, var(${cssVar}))`,
95+
fix: function(fixer) {
96+
return fixer.replaceText(node, node.type === 'Literal' ? `"${fixedString}"` : fixedString)
97+
}
98+
})
99+
}
100+
}
101+
})
102+
}
103+
})
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)