Skip to content

Commit

Permalink
Introduce a new rule to flag Link in text blocks without inline (#183)
Browse files Browse the repository at this point in the history
* 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
khiga8 and siddharthkp authored Jun 5, 2024
1 parent 9f9cfd2 commit 7bd36d2
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/happy-parents-invite.md
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
100 changes: 100 additions & 0 deletions docs/rules/a11y-link-in-text-block.md
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`.
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
'new-color-css-vars': require('./rules/new-color-css-vars'),
'a11y-explicit-heading': require('./rules/a11y-explicit-heading'),
'no-deprecated-props': require('./rules/no-deprecated-props'),
'a11y-link-in-text-block': require('./rules/a11y-link-in-text-block'),
},
configs: {
recommended: require('./configs/recommended'),
Expand Down
161 changes: 161 additions & 0 deletions src/rules/__tests__/a11y-link-in-text-block.test.js
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'}],
},
],
})
104 changes: 104 additions & 0 deletions src/rules/a11y-link-in-text-block.js
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',
})
}
}
}
},
}
},
}

0 comments on commit 7bd36d2

Please sign in to comment.