Skip to content

Commit

Permalink
Merge pull request #81 from primer/new-css-vars
Browse files Browse the repository at this point in the history
New rule: `new-color-css-vars`
  • Loading branch information
langermank authored Oct 3, 2023
2 parents 0cb5f8c + 821ef4d commit 35f0ffe
Show file tree
Hide file tree
Showing 8 changed files with 3,067 additions and 530 deletions.
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))'
}
]
},
{
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

0 comments on commit 35f0ffe

Please sign in to comment.