Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New rule: new-color-css-vars #81

Merged
merged 19 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-ads-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-primer-react": patch
---

New rule: `new-color-css-vars` to find/replace legacy CSS color vars in sx prop
1,281 changes: 754 additions & 527 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
"@changesets/changelog-github": "^0.4.0",
"@changesets/cli": "^2.16.0",
"@github/prettier-config": "0.0.4",
"@primer/primitives": "^7.11.14",
"eslint": "^8.0.1",
"@primer/primitives": "^7.14.0",
"eslint": "^8.42.0",
"jest": "^27.0.6"
},
"peerDependencies": {
"@primer/primitives": ">=4.6.2",
"eslint": "^8.0.1"
"eslint": "^8.42.0"
},
"prettier": "@github/prettier-config",
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions src/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = {
'primer-react/no-deprecated-colors': 'warn',
'primer-react/no-system-props': 'warn',
'primer-react/a11y-tooltip-interactive-trigger': 'error',
'primer-react/new-color-css-vars': 'error',
'primer-react/a11y-explicit-heading': 'error'
},
settings: {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module.exports = {
'no-deprecated-colors': require('./rules/no-deprecated-colors'),
'no-system-props': require('./rules/no-system-props'),
'a11y-tooltip-interactive-trigger': require('./rules/a11y-tooltip-interactive-trigger'),
'new-color-css-vars': require('./rules/new-color-css-vars'),
'a11y-explicit-heading': require('./rules/a11y-explicit-heading')
},
configs: {
Expand Down
132 changes: 132 additions & 0 deletions src/rules/__tests__/new-color-css-vars.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const rule = require('../new-color-css-vars')
const {RuleTester} = require('eslint')

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
}
})

ruleTester.run('no-color-css-vars', rule, {
valid: [
{
code: `{color: 'fg.default'}`
},
{
code: `<circle stroke="var(--color-border-default)" strokeWidth="2" />`
},
{
code: `<circle fill="var(--color-border-default)" strokeWidth="2" />`
},
{
code: `<div style={{ color: 'var(--color-border-default)' }}></div>`
},
{
code: `<Blankslate border></Blankslate>`
}
],
invalid: [
{
code: `<Button sx={{color: 'var(--color-fg-muted)'}}>Test</Button>`,
output: `<Button sx={{color: 'var(--fgColor-muted, var(--color-fg-muted))'}}>Test</Button>`,
errors: [
{
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will folks always need to include a fallback value or is this an interim step as we're moving over to the new system?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interim step! Once v8 is released and fully rolled out, we will refactor this to not include the fallback. We could also add some logic to remove the old fallbacks, but they won't really hurt anything if they stick around.

}
]
},
{
code: `
<Box sx={{
'&:hover [data-component="copy-link"] button, &:focus [data-component="copy-link"] button': {
color: 'var(--color-accent-fg)'
}
}}>
</Box>`,
output: `
<Box sx={{
'&:hover [data-component="copy-link"] button, &:focus [data-component="copy-link"] button': {
color: 'var(--fgColor-accent, var(--color-accent-fg))'
}
}}>
</Box>`,
errors: [
{
message: 'Replace var(--color-accent-fg) with var(--fgColor-accent, var(--color-accent-fg))'
}
]
},
{
code: `<Box sx={{boxShadow: '0 0 0 2px var(--color-canvas-subtle)'}} />`,
output: `<Box sx={{boxShadow: '0 0 0 2px var(--bgColor-muted, var(--color-canvas-subtle))'}} />`,
errors: [
{
message: 'Replace var(--color-canvas-subtle) with var(--bgColor-muted, var(--color-canvas-subtle))'
}
]
},
{
code: `<Box sx={{border: 'solid 2px var(--color-border-default)'}} />`,
output: `<Box sx={{border: 'solid 2px var(--borderColor-default, var(--color-border-default))'}} />`,
errors: [
{
message: 'Replace var(--color-border-default) with var(--borderColor-default, var(--color-border-default))'
}
]
},
{
code: `<Box sx={{backgroundColor: 'var(--color-canvas-default)'}} />`,
output: `<Box sx={{backgroundColor: 'var(--bgColor-default, var(--color-canvas-default))'}} />`,
errors: [
{
message: 'Replace var(--color-canvas-default) with var(--bgColor-default, var(--color-canvas-default))'
}
]
},
{
name: 'variable in scope',
code: `
const baseStyles = { color: 'var(--color-fg-muted)' }
export const Fixture = <Button sx={baseStyles}>Test</Button>
`,
output: `
const baseStyles = { color: 'var(--fgColor-muted, var(--color-fg-muted))' }
export const Fixture = <Button sx={baseStyles}>Test</Button>
`,
errors: [
{
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
}
]
},
{
name: 'merge in sx',
code: `
import {merge} from '@primer/react'
export const Fixture = props => <Button sx={merge({color: 'var(--color-fg-muted)'}, props.sx)}>Test</Button>
`,
output: `
import {merge} from '@primer/react'
export const Fixture = props => <Button sx={merge({color: 'var(--fgColor-muted, var(--color-fg-muted))'}, props.sx)}>Test</Button>
`,
errors: [
{
message: 'Replace var(--color-fg-muted) with var(--fgColor-muted, var(--color-fg-muted))'
}
]
},
{
code: `<Box sx={{borderColor: 'var(--color-border-default)'}} />`,
output: `<Box sx={{borderColor: 'var(--borderColor-default, var(--color-border-default))'}} />`,
errors: [
{
message: 'Replace var(--color-border-default) with var(--borderColor-default, var(--color-border-default))'
}
]
}
]
})
106 changes: 106 additions & 0 deletions src/rules/new-color-css-vars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
const cssVars = require('../utils/css-variable-map.json')

module.exports = {
meta: {
type: 'suggestion',
hasSuggestions: true,
fixable: 'code',
docs: {
description: 'Upgrade legacy CSS variables to Primitives v8 in sx prop'
},
schema: [
{
type: 'object',
properties: {
skipImportCheck: {
type: 'boolean'
},
checkAllStrings: {
type: 'boolean'
}
},
additionalProperties: false
}
]
},
/** @param {import('eslint').Rule.RuleContext} context */
create(context) {
const styledSystemProps = [
'bg',
'backgroundColor',
'color',
'borderColor',
'borderTopColor',
'borderRightColor',
'borderBottomColor',
'borderLeftColor',
'border',
'boxShadow',
'caretColor'
]

return {
/** @param {import('eslint').Rule.Node} node */
JSXAttribute(node) {
if (node.name.name === 'sx') {
if (node.value.expression.type === 'ObjectExpression') {
// example: sx={{ color: 'fg.default' }} or sx={{ ':hover': {color: 'fg.default'} }}
const rawText = context.sourceCode.getText(node.value)
checkForVariables(node.value, rawText)
} else if (node.value.expression.type === 'Identifier') {
// example: sx={baseStyles}
const variableScope = context.sourceCode.getScope(node.value.expression)
const variable = variableScope.set.get(node.value.expression.name)

// if variable is not defined in scope, give up (could be imported from different file)
if (!variable) return

const variableDeclarator = variable.identifiers[0].parent
const rawText = context.sourceCode.getText(variableDeclarator)
checkForVariables(variableDeclarator, rawText)
} else {
// worth a try!
const rawText = context.sourceCode.getText(node.value)
checkForVariables(node.value, rawText)
}
} else if (
styledSystemProps.includes(node.name.name) &&
node.value &&
node.value.type === 'Literal' &&
typeof node.value.value === 'string'
) {
checkForVariables(node.value, node.value.value)
}
}
}

function checkForVariables(node, rawText) {
// performance optimisation: exit early
if (!rawText.includes('var')) return

Object.keys(cssVars).forEach(cssVar => {
if (Array.isArray(cssVars[cssVar])) {
cssVars[cssVar].forEach(cssVarObject => {
const regex = new RegExp(`var\\(${cssVar}\\)`, 'g')
if (
cssVarObject.props.some(prop => rawText.includes(prop)) &&
regex.test(rawText) &&
!rawText.includes(cssVarObject.replacement)
) {
const fixedString = rawText.replace(regex, `var(${cssVarObject.replacement}, var(${cssVar}))`)
if (!rawText.includes(fixedString)) {
context.report({
node,
message: `Replace var(${cssVar}) with var(${cssVarObject.replacement}, var(${cssVar}))`,
fix: function(fixer) {
return fixer.replaceText(node, node.type === 'Literal' ? `"${fixedString}"` : fixedString)
}
})
}
}
})
}
})
}
}
}
Loading
Loading