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

Introduce a new rule to flag Link in text blocks without inline #183

Merged
merged 24 commits into from
Jun 5, 2024
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/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',
})
}
}
}
},
}
},
}
Loading