Skip to content

Commit 67629e9

Browse files
CopilotTylerJDev
andcommitted
Implement HTML anchor elements check in a11y-link-in-text-block rule
Co-authored-by: TylerJDev <[email protected]>
1 parent f46c671 commit 67629e9

File tree

3 files changed

+179
-6
lines changed

3 files changed

+179
-6
lines changed

docs/rules/a11y-link-in-text-block.md

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
# EXPERIMENTAL: Require `inline` prop on `<Link>` in text block
1+
# EXPERIMENTAL: Require `inline` prop on `<Link>` in text block and convert HTML anchors to Link components
22

33
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.
44

55
## Rule Details
66

77
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.
88

9+
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.
10+
911
Related: [WCAG 1.4.1 Use of Color issues](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html)
1012

11-
The lint rule will flag any `<Link>` without the `inline` property (equal to `true`) detected with string nodes on either side.
13+
The lint rule will flag:
14+
- Any `<Link>` without the `inline` property (equal to `true`) detected with string nodes on either side.
15+
- Any HTML `<a>` elements detected within a text block, with an autofix to convert them to `Link` components.
1216

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

15-
- `<Link className="...">` because there may be distinguishing styles applied.
19+
- `<Link className="...">` or `<a className="...">` because there may be distinguishing styles applied.
1620
- `<Link sx={{fontWeight:...}}>` or `<Link sx={{fontFamily:...}}>` because these technically may provide sufficient distinguishing styling.
17-
- `<Link>` where the only adjacent text is a period, since that can't really be considered a text block.
18-
- `<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.
19-
- `<Link>` that are nested inside of headings as these have often been breadcrumbs.
21+
- `<Link>` or `<a>` where the only adjacent text is a period, since that can't really be considered a text block.
22+
- `<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.
23+
- `<Link>` or `<a>` that are nested inside of headings as these have often been breadcrumbs.
2024

2125
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.
2226

@@ -46,6 +50,26 @@ function ExampleComponent() {
4650
}
4751
```
4852

53+
```jsx
54+
function ExampleComponent() {
55+
return (
56+
<SomeComponent>
57+
Please <a href="https://github.com">visit our site</a> for more information.
58+
</SomeComponent>
59+
)
60+
}
61+
```
62+
63+
```jsx
64+
function ExampleComponent() {
65+
return (
66+
<p>
67+
Learn more about <a href="https://github.com/pricing">GitHub plans</a> and pricing options.
68+
</p>
69+
)
70+
}
71+
```
72+
4973
👍 Examples of **correct** code for this rule:
5074

5175
```jsx
@@ -68,6 +92,30 @@ function ExampleComponent() {
6892
}
6993
```
7094

95+
```jsx
96+
import {Link} from '@primer/react'
97+
98+
function ExampleComponent() {
99+
return (
100+
<SomeComponent>
101+
Please <Link href="https://github.com">visit our site</Link> for more information.
102+
</SomeComponent>
103+
)
104+
}
105+
```
106+
107+
```jsx
108+
import {Link} from '@primer/react'
109+
110+
function ExampleComponent() {
111+
return (
112+
<p>
113+
Learn more about <Link href="https://github.com/pricing" inline>GitHub plans</Link> and pricing options.
114+
</p>
115+
)
116+
}
117+
```
118+
71119
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.
72120

73121
```jsx

src/rules/__tests__/a11y-link-in-text-block.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ ruleTester.run('a11y-link-in-text-block', rule, {
125125
<Link className='some-class'>Link text</Link>
126126
</p>
127127
`,
128+
// Valid HTML anchor examples
129+
`<h1>
130+
<a href="/home">Home</a>
131+
</h1>`,
132+
`<p>
133+
<a href="/about" className="custom-link">About us</a>
134+
</p>`,
135+
`<div>
136+
<a href="/contact"><CustomIcon /> Contact</a>
137+
</div>`,
138+
`<div>
139+
<a href="/link">Link</a>.
140+
</div>`,
128141
],
129142
invalid: [
130143
{
@@ -167,5 +180,26 @@ ruleTester.run('a11y-link-in-text-block', rule, {
167180
`,
168181
errors: [{messageId: 'linkInTextBlock'}],
169182
},
183+
// HTML anchor element tests
184+
{
185+
code: `<p>Please <a href="https://github.com">visit our site</a> for more information.</p>`,
186+
errors: [{messageId: 'htmlAnchorInTextBlock'}],
187+
output: `<p>Please <Link href="https://github.com">visit our site</Link> for more information.</p>`,
188+
},
189+
{
190+
code: `<div>Learn more about <a href="/pricing">pricing</a> options.</div>`,
191+
errors: [{messageId: 'htmlAnchorInTextBlock'}],
192+
output: `<div>Learn more about <Link href="/pricing">pricing</Link> options.</div>`,
193+
},
194+
{
195+
code: `<span>Check out <a href="https://github.com" target="_blank">GitHub</a> today!</span>`,
196+
errors: [{messageId: 'htmlAnchorInTextBlock'}],
197+
output: `<span>Check out <Link href="https://github.com" target="_blank">GitHub</Link> today!</span>`,
198+
},
199+
{
200+
code: `<p><a href="/home">Home page</a> has been updated.</p>`,
201+
errors: [{messageId: 'htmlAnchorInTextBlock'}],
202+
output: `<p><Link href="/home">Home page</Link> has been updated.</p>`,
203+
},
170204
],
171205
})

src/rules/a11y-link-in-text-block.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
const {isPrimerComponent} = require('../utils/is-primer-component')
22
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
33
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
4+
const {isHTMLElement} = require('../utils/is-html-element')
45

56
module.exports = {
67
meta: {
78
docs: {
89
url: require('../url')(module),
910
},
1011
type: 'problem',
12+
fixable: 'code',
1113
schema: [
1214
{
1315
properties: {
@@ -20,14 +22,103 @@ module.exports = {
2022
messages: {
2123
linkInTextBlock:
2224
'Links should have the inline prop if it appear in a text block and only uses color to distinguish itself from surrounding text.',
25+
htmlAnchorInTextBlock:
26+
'HTML anchor elements in text blocks should use the Link component from @primer/react instead.',
2327
},
2428
},
2529
create(context) {
2630
const sourceCode = context.sourceCode ?? context.getSourceCode()
31+
32+
// Helper function to check if a node is in a text block
33+
const isNodeInTextBlock = (node) => {
34+
let siblings = node.parent.children
35+
if (!siblings || siblings.length === 0) return false
36+
37+
// Filter out whitespace nodes
38+
siblings = siblings.filter(childNode => {
39+
return (
40+
!(childNode.type === 'JSXText' && /^\s+$/.test(childNode.value)) &&
41+
!(
42+
childNode.type === 'JSXExpressionContainer' &&
43+
childNode.expression.type === 'Literal' &&
44+
/^\s+$/.test(childNode.expression.value)
45+
) &&
46+
!(childNode.type === 'Literal' && /^\s+$/.test(childNode.value))
47+
)
48+
})
49+
50+
const index = siblings.findIndex(childNode => {
51+
return childNode.range === node.range
52+
})
53+
54+
const prevSibling = siblings[index - 1]
55+
const nextSibling = siblings[index + 1]
56+
57+
const prevSiblingIsText = prevSibling && prevSibling.type === 'JSXText'
58+
const nextSiblingIsText = nextSibling && nextSibling.type === 'JSXText'
59+
60+
// If there's text on either side
61+
if (prevSiblingIsText || nextSiblingIsText) {
62+
// Skip if the only text adjacent to the link is a period
63+
if (!prevSiblingIsText && /^\s*\.+\s*$/.test(nextSibling.value)) {
64+
return false
65+
}
66+
return true
67+
}
68+
69+
return false
70+
}
71+
2772
return {
2873
JSXElement(node) {
2974
const name = getJSXOpeningElementName(node.openingElement)
75+
const parentName = node.parent.openingElement?.name?.name
76+
const parentsToSkip = ['Heading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
77+
78+
// Check for HTML anchor elements
3079
if (
80+
isHTMLElement(node.openingElement) &&
81+
name === 'a' &&
82+
node.parent.children
83+
) {
84+
// Skip if anchor is nested inside of a heading
85+
if (parentsToSkip.includes(parentName)) return
86+
87+
// Skip if anchor has className (might have distinguishing styles)
88+
const classNameAttribute = getJSXOpeningElementAttribute(node.openingElement, 'className')
89+
if (classNameAttribute) return
90+
91+
// Check for anchor in text block
92+
if (isNodeInTextBlock(node)) {
93+
// Skip if anchor child is a JSX element
94+
const jsxElementChildren = node.children.filter(child => child.type === 'JSXElement')
95+
if (jsxElementChildren.length > 0) return
96+
97+
// Report and autofix
98+
context.report({
99+
node,
100+
messageId: 'htmlAnchorInTextBlock',
101+
fix(fixer) {
102+
// Get all attributes from the anchor to transfer to Link
103+
const attributes = node.openingElement.attributes
104+
.map(attr => sourceCode.getText(attr))
105+
.join(' ')
106+
107+
// Create the Link component opening and closing tags
108+
const openingTag = `<Link ${attributes}>`
109+
const closingTag = '</Link>'
110+
111+
// Apply fixes to the opening and closing tags
112+
const openingFix = fixer.replaceText(node.openingElement, openingTag)
113+
const closingFix = fixer.replaceText(node.closingElement, closingTag)
114+
115+
return [openingFix, closingFix]
116+
}
117+
})
118+
}
119+
}
120+
// Check for Primer Link component
121+
else if (
31122
isPrimerComponent(node.openingElement.name, sourceCode.getScope(node)) &&
32123
name === 'Link' &&
33124
node.parent.children

0 commit comments

Comments
 (0)