From f46c6710259616c31cbc1a7586d6bc30bbff2c03 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 May 2025 18:41:18 +0000
Subject: [PATCH 1/5] Initial plan for issue
From 67629e9b3f4b4dbc7f280b42a72de0c12f7a86df Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 May 2025 18:50:22 +0000
Subject: [PATCH 2/5] Implement HTML anchor elements check in
a11y-link-in-text-block rule
Co-authored-by: TylerJDev <26746305+TylerJDev@users.noreply.github.com>
---
docs/rules/a11y-link-in-text-block.md | 60 ++++++++++--
.../__tests__/a11y-link-in-text-block.test.js | 34 +++++++
src/rules/a11y-link-in-text-block.js | 91 +++++++++++++++++++
3 files changed, 179 insertions(+), 6 deletions(-)
diff --git a/docs/rules/a11y-link-in-text-block.md b/docs/rules/a11y-link-in-text-block.md
index 07538d94..201e9e33 100644
--- a/docs/rules/a11y-link-in-text-block.md
+++ b/docs/rules/a11y-link-in-text-block.md
@@ -1,4 +1,4 @@
-# EXPERIMENTAL: Require `inline` prop on `` in text block
+# EXPERIMENTAL: Require `inline` prop on `` 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.
@@ -6,17 +6,21 @@ This is an experimental rule. If you suspect any false positives reported by thi
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 (``) 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 `` without the `inline` property (equal to `true`) detected with string nodes on either side.
+The lint rule will flag:
+- Any `` without the `inline` property (equal to `true`) detected with string nodes on either side.
+- Any HTML `` 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:
-- `` because there may be distinguishing styles applied.
+- `` or `` because there may be distinguishing styles applied.
- `` or `` because these technically may provide sufficient distinguishing styling.
-- `` where the only adjacent text is a period, since that can't really be considered a text block.
-- `` 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.
-- `` that are nested inside of headings as these have often been breadcrumbs.
+- `` or `` where the only adjacent text is a period, since that can't really be considered a text block.
+- `` or `` 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.
+- `` or `` 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.
@@ -46,6 +50,26 @@ function ExampleComponent() {
}
```
+```jsx
+function ExampleComponent() {
+ return (
+
+ Learn more about GitHub plans and pricing options.
+
+ Learn more about GitHub plans and pricing options.
+
+ About us +
`, + `Please visit our site for more information.
`, + errors: [{messageId: 'htmlAnchorInTextBlock'}], + output: `Please visit our site for more information.
`, + }, + { + code: `Home page has been updated.
`, + errors: [{messageId: 'htmlAnchorInTextBlock'}], + output: `Home page has been updated.
`, + }, ], }) diff --git a/src/rules/a11y-link-in-text-block.js b/src/rules/a11y-link-in-text-block.js index 815e4b70..32aa0a51 100644 --- a/src/rules/a11y-link-in-text-block.js +++ b/src/rules/a11y-link-in-text-block.js @@ -1,6 +1,7 @@ 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: { @@ -8,6 +9,7 @@ module.exports = { url: require('../url')(module), }, type: 'problem', + fixable: 'code', schema: [ { properties: { @@ -20,14 +22,103 @@ 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 + }) + + 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) + 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 + + // 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 = `` + const closingTag = '' + + // 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 From 21c454006ec54e935b4426b4c4579ec7b20b1942 Mon Sep 17 00:00:00 2001 From: Tyler Jones- Learn more about GitHub plans and pricing options. + Learn more about{' '} + + GitHub plans + {' '} + and pricing options.
) } diff --git a/src/rules/a11y-link-in-text-block.js b/src/rules/a11y-link-in-text-block.js index 32aa0a51..ccc81c58 100644 --- a/src/rules/a11y-link-in-text-block.js +++ b/src/rules/a11y-link-in-text-block.js @@ -28,12 +28,12 @@ module.exports = { }, create(context) { const sourceCode = context.sourceCode ?? context.getSourceCode() - + // Helper function to check if a node is in a text block - const isNodeInTextBlock = (node) => { + const isNodeInTextBlock = node => { let siblings = node.parent.children if (!siblings || siblings.length === 0) return false - + // Filter out whitespace nodes siblings = siblings.filter(childNode => { return ( @@ -46,17 +46,17 @@ module.exports = { !(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 there's text on either side if (prevSiblingIsText || nextSiblingIsText) { // Skip if the only text adjacent to the link is a period @@ -65,55 +65,49 @@ module.exports = { } return true } - + return false } - + return { JSXElement(node) { const name = getJSXOpeningElementName(node.openingElement) 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 - ) { + 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 - + // 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(' ') - + const attributes = node.openingElement.attributes.map(attr => sourceCode.getText(attr)).join(' ') + // Create the Link component opening and closing tags const openingTag = `` const closingTag = '' - + // 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] - } + }, }) } } From a4ad769cee319e67e7976a4c0764d43e46f6e9f0 Mon Sep 17 00:00:00 2001 From: Tyler Jones