Skip to content

Update a11y-link-in-text-block rule to include HTML anchor elements #345

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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/odd-pumas-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-primer-react': minor
---

Detect HTML anchor elements (`<a>`) in `a11y-link-in-text-block` rule
65 changes: 59 additions & 6 deletions docs/rules/a11y-link-in-text-block.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
# EXPERIMENTAL: Require `inline` prop on `<Link>` in text block
# EXPERIMENTAL: Require `inline` prop on `<Link>` in text block and convert HTML anchors to Link components

This is an experimental rule. If you suspect any false positives reported by this rule, please file an issue so we can make this rule better.

## Rule Details

The `Link` component should have the `inline` prop when it is used within a text block and has no styles (aside from color) to distinguish itself from surrounding plain text.

Additionally, HTML anchor elements (`<a>`) in text blocks should be converted to use the `Link` component from `@primer/react` to maintain consistent styling and accessibility.

Related: [WCAG 1.4.1 Use of Color issues](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html)

The lint rule will flag any `<Link>` without the `inline` property (equal to `true`) detected with string nodes on either side.
The lint rule will flag:

- Any `<Link>` without the `inline` property (equal to `true`) detected with string nodes on either side.
- Any HTML `<a>` elements detected within a text block, with an autofix to convert them to `Link` components.

There are certain edge cases that the linter skips to avoid false positives including:

- `<Link className="...">` because there may be distinguishing styles applied.
- `<Link className="...">` or `<a className="...">` because there may be distinguishing styles applied.
Copy link
Preview

Copilot AI May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation should also mention that <a> elements with an id attribute are skipped by the rule, since the code filters on id similarly to className.

Suggested change
- `<Link className="...">` or `<a className="...">` because there may be distinguishing styles applied.
- `<Link className="...">`, `<a className="...">`, or `<a id="...">` because there may be distinguishing styles or behaviors applied.

Copilot uses AI. Check for mistakes.

- `<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.
- `<Link>` or `<a>` where the only adjacent text is a period, since that can't really be considered a text block.
- `<Link>` or `<a>` 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>` or `<a>` that are nested inside of headings as these have often been breadcrumbs.

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.

Expand Down Expand Up @@ -46,6 +51,26 @@ function ExampleComponent() {
}
```

```jsx
function ExampleComponent() {
return (
<SomeComponent>
Please <a href="https://github.com">visit our site</a> for more information.
</SomeComponent>
)
}
```

```jsx
function ExampleComponent() {
return (
<p>
Learn more about <a href="https://github.com/pricing">GitHub plans</a> and pricing options.
</p>
)
}
```

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

```jsx
Expand All @@ -68,6 +93,34 @@ function ExampleComponent() {
}
```

```jsx
import {Link} from '@primer/react'

function ExampleComponent() {
return (
<SomeComponent>
Please <Link href="https://github.com">visit our site</Link> for more information.
</SomeComponent>
)
}
```

```jsx
import {Link} from '@primer/react'

function ExampleComponent() {
return (
<p>
Learn more about{' '}
<Link href="https://github.com/pricing" inline>
GitHub plans
</Link>{' '}
and pricing options.
</p>
)
}
```

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
Expand Down
41 changes: 39 additions & 2 deletions src/rules/__tests__/a11y-link-in-text-block.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ 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';
Expand Down Expand Up @@ -125,6 +125,22 @@ ruleTester.run('a11y-link-in-text-block', rule, {
<Link className='some-class'>Link text</Link>
</p>
`,
// Valid HTML anchor examples
`<h1>
<a href="/home">Home</a>
</h1>`,
`<p>
<a href="/about" className="custom-link">About us</a>
</p>`,
`<div>
<a href="/contact"><CustomIcon /> Contact</a>
</div>`,
`<div>
<a href="/link">Link</a>.
</div>`,
`<div>
<a href="/link" id="custom-link">Link</a>.
</div>`,
],
invalid: [
{
Expand Down Expand Up @@ -167,5 +183,26 @@ ruleTester.run('a11y-link-in-text-block', rule, {
`,
errors: [{messageId: 'linkInTextBlock'}],
},
// HTML anchor element tests
{
code: `<p>Please <a href="https://github.com">visit our site</a> for more information.</p>`,
errors: [{messageId: 'htmlAnchorInTextBlock'}],
output: `<p>Please <Link href="https://github.com">visit our site</Link> for more information.</p>`,
},
{
code: `<div>Learn more about <a href="/pricing">pricing</a> options.</div>`,
errors: [{messageId: 'htmlAnchorInTextBlock'}],
output: `<div>Learn more about <Link href="/pricing">pricing</Link> options.</div>`,
},
{
code: `<span>Check out <a href="https://github.com" target="_blank">GitHub</a> today!</span>`,
errors: [{messageId: 'htmlAnchorInTextBlock'}],
output: `<span>Check out <Link href="https://github.com" target="_blank">GitHub</Link> today!</span>`,
},
{
code: `<p><a href="/home">Home page</a> has been updated.</p>`,
errors: [{messageId: 'htmlAnchorInTextBlock'}],
output: `<p><Link href="/home">Home page</Link> has been updated.</p>`,
},
],
})
91 changes: 90 additions & 1 deletion src/rules/a11y-link-in-text-block.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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')
const {isHTMLElement} = require('../utils/is-html-element')

module.exports = {
meta: {
docs: {
url: require('../url')(module),
},
type: 'problem',
fixable: 'code',
schema: [
{
properties: {
Expand All @@ -20,14 +22,101 @@ module.exports = {
messages: {
linkInTextBlock:
'Links should have the inline prop if it appear in a text block and only uses color to distinguish itself from surrounding text.',
htmlAnchorInTextBlock:
'HTML anchor elements in text blocks should use the Link component from @primer/react instead.',
},
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode()

// Helper function to check if a node is in a text block
const isNodeInTextBlock = node => {
let siblings = node.parent.children
if (!siblings || siblings.length === 0) return false

// Filter out whitespace nodes
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
Copy link
Preview

Copilot AI May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing arrays with === always returns false; use object identity (e.g., childNode === node) or compare numeric range values instead to correctly find the node’s index.

Suggested change
return childNode.range === node.range
return (
childNode.range[0] === node.range[0] &&
childNode.range[1] === node.range[1]
)

Copilot uses AI. Check for mistakes.

})

const prevSibling = siblings[index - 1]
const nextSibling = siblings[index + 1]

const prevSiblingIsText = prevSibling && prevSibling.type === 'JSXText'
const nextSiblingIsText = nextSibling && nextSibling.type === 'JSXText'

// If there's text on either side
if (prevSiblingIsText || nextSiblingIsText) {
// Skip if the only text adjacent to the link is a period
if (!prevSiblingIsText && /^\s*\.+\s*$/.test(nextSibling.value)) {
return false
}
return true
}

return false
}

return {
JSXElement(node) {
const name = getJSXOpeningElementName(node.openingElement)
if (
const parentName = node.parent.openingElement?.name?.name
const parentsToSkip = ['Heading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']

// Check for HTML anchor elements
if (isHTMLElement(node.openingElement) && name === 'a' && node.parent.children) {
// Skip if anchor is nested inside of a heading
if (parentsToSkip.includes(parentName)) return

// Skip if anchor has className (might have distinguishing styles)
const classNameAttribute = getJSXOpeningElementAttribute(node.openingElement, 'className')
if (classNameAttribute) return

// Skip if anchor has an ID (might have distinguishing styles)
const idAttribute = getJSXOpeningElementAttribute(node.openingElement, 'id')
if (idAttribute) return

// Check for anchor in text block
if (isNodeInTextBlock(node)) {
// Skip if anchor child is a JSX element
const jsxElementChildren = node.children.filter(child => child.type === 'JSXElement')
if (jsxElementChildren.length > 0) return

// Report and autofix
context.report({
node,
messageId: 'htmlAnchorInTextBlock',
fix(fixer) {
// Get all attributes from the anchor to transfer to Link
const attributes = node.openingElement.attributes.map(attr => sourceCode.getText(attr)).join(' ')

// Create the Link component opening and closing tags
const openingTag = `<Link ${attributes}>`
Copy link
Preview

Copilot AI May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When converting HTML anchors to Link, the rule should add the inline prop for consistency with the linkInTextBlock rule; include inline in the opening tag (e.g., <Link inline ${attributes}>).

Suggested change
const openingTag = `<Link ${attributes}>`
const openingTag = `<Link inline ${attributes}>`

Copilot uses AI. Check for mistakes.

const closingTag = '</Link>'

// Apply fixes to the opening and closing tags
const openingFix = fixer.replaceText(node.openingElement, openingTag)
const closingFix = fixer.replaceText(node.closingElement, closingTag)

return [openingFix, closingFix]
},
})
}
}
// Check for Primer Link component
else if (
isPrimerComponent(node.openingElement.name, sourceCode.getScope(node)) &&
name === 'Link' &&
node.parent.children
Expand Down