Skip to content

Commit

Permalink
New rule: check the new color CSS vars have a fallback (#122)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
lukasoppermann and langermank authored Nov 29, 2023
1 parent 7f4c467 commit 3bc226a
Show file tree
Hide file tree
Showing 7 changed files with 476 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/wet-lies-visit.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-have-fallback: checks that if a new color var is used, it has a fallback value
25 changes: 25 additions & 0 deletions docs/rules/new-color-css-vars-have-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Ensure new Primitive v8 color CSS vars have a fallback

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.

## Rule Details

This rule refers to a JSON file that lists all the new color tokens

```json
["--fgColor-default", "--fgColor-muted", "--fgColor-onEmphasis"]
```

If it finds that one of these tokens is used without a fallback, it will throw an error.

👎 Examples of **incorrect** code for this rule

```jsx
<Button sx={{color: 'var(--fgColor-muted)'}}>Test</Button>
```

👍 Examples of **correct** code for this rule:

```jsx
<Button sx={{color: 'var(--fgColor-muted, var(--color-fg-muted))'}}>Test</Button>
```
1 change: 1 addition & 0 deletions src/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'primer-react/a11y-tooltip-interactive-trigger': 'error',
'primer-react/new-color-css-vars': 'error',
'primer-react/a11y-explicit-heading': 'error',
'primer-react/new-color-css-vars-have-fallback': 'error',
},
settings: {
github: {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
'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'),
'new-color-css-vars-have-fallback': require('./rules/new-color-css-vars-have-fallback'),
},
configs: {
recommended: require('./configs/recommended'),
Expand Down
31 changes: 31 additions & 0 deletions src/rules/__tests__/new-css-vars-have-fallback.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const rule = require('../new-color-css-vars-have-fallback')
const {RuleTester} = require('eslint')

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

ruleTester.run('new-color-css-vars-have-fallback', rule, {
valid: [
{
code: `<circle stroke="var(--fgColor-muted, var(--color-fg-muted))" strokeWidth="2" />`,
},
],
invalid: [
{
code: `<circle stroke="var(--fgColor-muted)" strokeWidth="2" />`,
errors: [
{
message:
'Expected a fallback value for CSS variable --fgColor-muted. New color variables fallbacks, check primer.style/primitives to find the correct value.',
},
],
},
],
})
87 changes: 87 additions & 0 deletions src/rules/new-color-css-vars-have-fallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const cssVars = require('../utils/new-color-css-vars-map')

const reportError = (propertyName, valueNode, context) => {
// performance optimisation: exit early
if (valueNode.type !== 'Literal' && valueNode.type !== 'TemplateElement') return
// get property value
const value = valueNode.type === 'Literal' ? valueNode.value : valueNode.value.cooked
// return if value is not a string
if (typeof value !== 'string') return
// return if value does not include variable
if (!value.includes('var(')) return

const varRegex = /var\([^(),)]+\)/g

const match = value.match(varRegex)
// return if no matches
if (!match) return
const vars = match.flatMap(match =>
match
.slice(4, -1)
.trim()
.split(/\s*,\s*/g),
)
for (const cssVar of vars) {
// return if no repalcement exists
if (!cssVars?.includes(cssVar)) return
// report the error
context.report({
node: valueNode,
message: `Expected a fallback value for CSS variable ${cssVar}. New color variables fallbacks, check primer.style/primitives to find the correct value.`,
})
}
}

const reportOnObject = (node, context) => {
const propertyName = node.key.name
if (node.value?.type === 'Literal') {
reportError(propertyName, node.value, context)
} else if (node.value?.type === 'ConditionalExpression') {
reportError(propertyName, node.value.consequent, context)
reportError(propertyName, node.value.alternate, context)
}
}

const reportOnProperty = (node, context) => {
const propertyName = node.name.name
if (node.value?.type === 'Literal') {
reportError(propertyName, node.value, context)
} else if (node.value?.type === 'JSXExpressionContainer' && node.value.expression?.type === 'ConditionalExpression') {
reportError(propertyName, node.value.expression.consequent, context)
reportError(propertyName, node.value.expression.alternate, context)
}
}

const reportOnValue = (node, context) => {
if (node?.type === 'Literal') {
reportError(undefined, node, context)
} else if (node?.type === 'JSXExpressionContainer' && node.expression?.type === 'ConditionalExpression') {
reportError(undefined, node.value.expression.consequent, context)
reportError(undefined, node.value.expression.alternate, context)
}
}

const reportOnTemplateElement = (node, context) => {
reportError(undefined, node, context)
}

module.exports = {
meta: {
type: 'suggestion',
},
/** @param {import('eslint').Rule.RuleContext} context */
create(context) {
return {
// sx OR style property on elements
['JSXAttribute:matches([name.name=sx], [name.name=style]) ObjectExpression Property']: node =>
reportOnObject(node, context),
// property on element like stroke or fill
['JSXAttribute[name.name!=sx][name.name!=style]']: node => reportOnProperty(node, context),
// variable that is a value
[':matches(VariableDeclarator, ReturnStatement) > Literal']: node => reportOnValue(node, context),
// variable that is a value
[':matches(VariableDeclarator, ReturnStatement) > TemplateElement']: node =>
reportOnTemplateElement(node, context),
}
},
}
Loading

0 comments on commit 3bc226a

Please sign in to comment.