-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce a new rule to flag Link in text blocks without
inline
(#183)
* feat: Flag link in text block * Cover newspace scenarios * Run lint * Passing tests, update configs * Cover scenario where Link has no parent with children * Fix false positives * additional test cases * All tests pass * Fix bug * Do not flag if only adjacent text is a dot * Fix deprecated notice * Add doc * Clean up * Fix bug * Fix format * Add test for underline * Skip font family * Update a11y-link-in-text-block.js * Update a11y-link-in-text-block.md * Run lint * Create happy-parents-invite.md * Update .changeset/happy-parents-invite.md Co-authored-by: Siddharth Kshetrapal <[email protected]> * Apply suggestions from code review --------- Co-authored-by: Siddharth Kshetrapal <[email protected]>
- Loading branch information
1 parent
9f9cfd2
commit 7bd36d2
Showing
5 changed files
with
371 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"eslint-plugin-primer-react": patch | ||
--- | ||
|
||
New rule to flag `Link` in text block missing the `inline` prop |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
## Require `inline` prop on `<Link>` component inside a text block | ||
|
||
The `Link` component should have the `inline` prop when it is used inside of a text block. | ||
|
||
## Rule Details | ||
|
||
This rule enforces setting `inline` on the `<Link>` component when a `<Link>` is detected inside of a text block without distiguishable styling. | ||
|
||
The lint rule will essentially flag any `<Link>` without the `inline` property (equal to `true`) detected with string nodes on either side. | ||
|
||
This rule will not catch all instances of link in text block due to the limitations of static analysis, so be sure to also have in-browser checks in place such as the [link-in-text-block Axe rule](https://dequeuniversity.com/rules/axe/4.9/link-in-text-block) for additional coverage. | ||
|
||
The edge cases that the linter skips to avoid false positives will include: | ||
|
||
- `<Link sx={{fontWeight:...}}>` or `<Link sx={{fontFamily:...}}>` because these technically may provide sufficient distinguishing styling. | ||
- `<Link>` where the only adjacent text is a period, since that can't really be considered a text block. | ||
- `<Link>` where the children is a JSX component, rather than a string literal, because then it might be an icon link rather than a text link. | ||
- `<Link>` that are nested inside of headings as these have often been breadcrumbs. | ||
|
||
👎 Examples of **incorrect** code for this rule | ||
|
||
```jsx | ||
import {Link} from '@primer/react' | ||
|
||
function ExampleComponent() { | ||
return ( | ||
<SomeComponent> | ||
<Link>Say hello</Link> or not. | ||
</SomeComponent> | ||
) | ||
} | ||
``` | ||
|
||
```jsx | ||
import {Link} from '@primer/react' | ||
|
||
function ExampleComponent() { | ||
return ( | ||
<SomeComponent> | ||
Say hello or <Link>sign-up</Link>. | ||
</SomeComponent> | ||
) | ||
} | ||
``` | ||
|
||
👍 Examples of **correct** code for this rule: | ||
|
||
```jsx | ||
function ExampleComponent() { | ||
return ( | ||
<SomeComponent> | ||
<Link inline>Say hello</Link> or not. | ||
</SomeComponent> | ||
) | ||
} | ||
``` | ||
|
||
```jsx | ||
function ExampleComponent() { | ||
return ( | ||
<SomeComponent> | ||
<Link inline={true}>Say hello</Link> or not. | ||
</SomeComponent> | ||
) | ||
} | ||
``` | ||
|
||
This rule will skip `Link`s containing JSX elements to minimize potential false positives because it is possible the JSX element sufficiently distinguishes the link from surrounding text. | ||
|
||
```jsx | ||
function ExampleComponent() { | ||
return ( | ||
<SomeComponent> | ||
<Link> | ||
<SomeAvatar /> | ||
@monalisa | ||
</Link>{' '} | ||
commented on your account. | ||
</SomeComponent> | ||
) | ||
} | ||
``` | ||
|
||
This rule will skip `Link`s nested inside of a `Heading`. | ||
|
||
```jsx | ||
function ExampleComponent() { | ||
return ( | ||
<Heading> | ||
<Link>Previous location</Link>/ Current location | ||
</Heading> | ||
) | ||
} | ||
``` | ||
|
||
## Options | ||
|
||
- `skipImportCheck` (default: `false`) | ||
|
||
By default, the `a11y-explicit-heading` rule will only check for `<Heading>` components imported directly from `@primer/react`. You can disable this behavior by setting `skipImportCheck` to `true`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
const rule = require('../a11y-link-in-text-block') | ||
const {RuleTester} = require('eslint') | ||
|
||
const ruleTester = new RuleTester({ | ||
parserOptions: { | ||
ecmaVersion: 'latest', | ||
sourceType: 'module', | ||
ecmaFeatures: { | ||
jsx: true, | ||
}, | ||
}, | ||
}) | ||
|
||
ruleTester.run('a11y-link-in-text-block', rule, { | ||
valid: [ | ||
`import {Link} from '@primer/react'; | ||
<Box> | ||
<Link href="something"> | ||
Blah blah | ||
</Link>{' '} | ||
. | ||
</Box> | ||
`, | ||
`import {Text, Link} from '@primer/react'; | ||
<Something> | ||
<Link href='blah'> | ||
blah | ||
</Link> | ||
</Something> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<p>bla blah <Link inline={true}>Link level 1</Link></p>; | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<p>bla blah<Link inline>Link level 1</Link></p>; | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<><span>something</span><Link inline={true}>Link level 1</Link></>; | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<Link>Link level 1</Link>; | ||
`, | ||
`import {Heading, Link} from '@primer/react'; | ||
<Heading> | ||
<Link>Link level 1</Link> | ||
hello | ||
</Heading> | ||
`, | ||
`import {Heading, Link} from '@primer/react'; | ||
<Heading as="h2"> | ||
<Link href={somePath}> | ||
Breadcrumb | ||
</Link> | ||
Create a thing | ||
</Heading> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<div> | ||
<h2> | ||
<Link href={somePath}> | ||
Breadcrumb | ||
</Link> | ||
</h2> | ||
Create a thing | ||
</div> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<div> | ||
<Link href={somePath}> | ||
<GitHubAvatar />{owner} | ||
</Link>{' '} | ||
last edited{' '} | ||
</div> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<span> | ||
by | ||
<Link href="something" sx={{p: 2, fontWeight: 'bold'}}> | ||
Blah blah | ||
</Link> | ||
</span> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<span> | ||
by | ||
<Link href="something" sx={{fontWeight: 'bold'}}> | ||
Blah blah | ||
</Link> | ||
</span> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<span> | ||
by | ||
<Link href="something" sx={{fontFamily: 'mono'}}> | ||
Blah blah | ||
</Link> | ||
</span> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<Box> | ||
<Link href="something"> | ||
Blah blah | ||
</Link>{' '} | ||
. | ||
</Box> | ||
`, | ||
`import {Link} from '@primer/react'; | ||
<Heading sx={{fontSize: 1, mb: 3}} as="h3"> | ||
In addition,{' '} | ||
<Link href="https://github.com/pricing" target="_blank"> | ||
GitHub Team | ||
</Link>{' '} | ||
includes: | ||
</Heading> | ||
`, | ||
], | ||
invalid: [ | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<p>bla blah<Link>Link level 1</Link></p> | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<p><Link>Link level 1</Link> something something</p> | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<p>bla blah<Link inline={false}>Link level 1</Link></p> | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<Box>Something something{' '} | ||
<Link>Link level 1</Link> | ||
</Box> | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<>blah blah blah{' '} | ||
<Link>Link level 1</Link></>; | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
{ | ||
code: `import {Link} from '@primer/react'; | ||
<>blah blah blah{' '} | ||
<Link underline>Link level 1</Link></>; | ||
`, | ||
errors: [{messageId: 'linkInTextBlock'}], | ||
}, | ||
], | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
const {isPrimerComponent} = require('../utils/is-primer-component') | ||
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') | ||
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute') | ||
|
||
module.exports = { | ||
meta: { | ||
type: 'problem', | ||
schema: [ | ||
{ | ||
properties: { | ||
skipImportCheck: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
}, | ||
], | ||
messages: { | ||
linkInTextBlock: '<Link>s that are used within a text block should have the inline prop.', | ||
}, | ||
}, | ||
create(context) { | ||
const sourceCode = context.sourceCode ?? context.getSourceCode() | ||
return { | ||
JSXElement(node) { | ||
const name = getJSXOpeningElementName(node.openingElement) | ||
if ( | ||
isPrimerComponent(node.openingElement.name, sourceCode.getScope(node)) && | ||
name === 'Link' && | ||
node.parent.children | ||
) { | ||
let siblings = node.parent.children | ||
const parentName = node.parent.openingElement?.name?.name | ||
// Skip if Link is nested inside of a heading. | ||
const parentsToSkip = ['Heading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] | ||
if (parentsToSkip.includes(parentName)) return | ||
if (siblings.length > 0) { | ||
siblings = siblings.filter(childNode => { | ||
return ( | ||
!(childNode.type === 'JSXText' && /^\s+$/.test(childNode.value)) && | ||
!( | ||
childNode.type === 'JSXExpressionContainer' && | ||
childNode.expression.type === 'Literal' && | ||
/^\s+$/.test(childNode.expression.value) | ||
) && | ||
!(childNode.type === 'Literal' && /^\s+$/.test(childNode.value)) | ||
) | ||
}) | ||
const index = siblings.findIndex(childNode => { | ||
return childNode.range === node.range | ||
}) | ||
const prevSibling = siblings[index - 1] | ||
const nextSibling = siblings[index + 1] | ||
|
||
const prevSiblingIsText = prevSibling && prevSibling.type === 'JSXText' | ||
const nextSiblingIsText = nextSibling && nextSibling.type === 'JSXText' | ||
if (prevSiblingIsText || nextSiblingIsText) { | ||
// Skip if the only text adjacent to the link is a period, then skip it. | ||
if (!prevSiblingIsText && /^\s*\.+\s*$/.test(nextSibling.value)) { | ||
return | ||
} | ||
const sxAttribute = getJSXOpeningElementAttribute(node.openingElement, 'sx') | ||
const inlineAttribute = getJSXOpeningElementAttribute(node.openingElement, 'inline') | ||
|
||
// Skip if Link child is a JSX element. | ||
const jsxElementChildren = node.children.filter(child => { | ||
return child.type === 'JSXElement' | ||
}) | ||
if (jsxElementChildren.length > 0) return | ||
|
||
// Skip if fontWeight or fontFamily is set via the sx prop since these may technically be considered sufficiently distinguishing styles that don't use color. | ||
if ( | ||
sxAttribute && | ||
sxAttribute?.value?.expression && | ||
sxAttribute.value.expression.type === 'ObjectExpression' && | ||
sxAttribute.value.expression.properties && | ||
sxAttribute.value.expression.properties.length > 0 | ||
) { | ||
const fontStyleProperty = sxAttribute.value.expression.properties.filter(property => { | ||
return property.key.name === 'fontWeight' || property.key.name === 'fontFamily' | ||
}) | ||
if (fontStyleProperty.length > 0) return | ||
} | ||
if (inlineAttribute) { | ||
if (!inlineAttribute.value) { | ||
return | ||
} else if (inlineAttribute.value.type === 'JSXExpressionContainer') { | ||
if (inlineAttribute.value.expression.type === 'Literal') { | ||
if (inlineAttribute.value.expression.value === true) { | ||
return | ||
} | ||
} | ||
} | ||
} | ||
context.report({ | ||
node, | ||
messageId: 'linkInTextBlock', | ||
}) | ||
} | ||
} | ||
} | ||
}, | ||
} | ||
}, | ||
} |