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 ( + + Please visit our site for more information. + + ) +} +``` + +```jsx +function ExampleComponent() { + return ( +

+ Learn more about GitHub plans and pricing options. +

+ ) +} +``` + 👍 Examples of **correct** code for this rule: ```jsx @@ -68,6 +92,30 @@ function ExampleComponent() { } ``` +```jsx +import {Link} from '@primer/react' + +function ExampleComponent() { + return ( + + Please visit our site for more information. + + ) +} +``` + +```jsx +import {Link} from '@primer/react' + +function ExampleComponent() { + return ( +

+ Learn more about GitHub plans and pricing options. +

+ ) +} +``` + 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 diff --git a/src/rules/__tests__/a11y-link-in-text-block.test.js b/src/rules/__tests__/a11y-link-in-text-block.test.js index 05b5c6eb..95b6cc9b 100644 --- a/src/rules/__tests__/a11y-link-in-text-block.test.js +++ b/src/rules/__tests__/a11y-link-in-text-block.test.js @@ -125,6 +125,19 @@ ruleTester.run('a11y-link-in-text-block', rule, { Link text

`, + // Valid HTML anchor examples + `

+ Home +

`, + `

+ About us +

`, + `
+ Contact +
`, + `
+ Link. +
`, ], invalid: [ { @@ -167,5 +180,26 @@ ruleTester.run('a11y-link-in-text-block', rule, { `, errors: [{messageId: 'linkInTextBlock'}], }, + // HTML anchor element tests + { + code: `

Please visit our site for more information.

`, + errors: [{messageId: 'htmlAnchorInTextBlock'}], + output: `

Please visit our site for more information.

`, + }, + { + code: `
Learn more about pricing options.
`, + errors: [{messageId: 'htmlAnchorInTextBlock'}], + output: `
Learn more about pricing options.
`, + }, + { + code: `Check out GitHub today!`, + errors: [{messageId: 'htmlAnchorInTextBlock'}], + output: `Check out GitHub today!`, + }, + { + 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 Date: Fri, 23 May 2025 10:24:20 -0400 Subject: [PATCH 3/5] Format --- docs/rules/a11y-link-in-text-block.md | 7 ++++- src/rules/a11y-link-in-text-block.js | 44 ++++++++++++--------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/docs/rules/a11y-link-in-text-block.md b/docs/rules/a11y-link-in-text-block.md index 201e9e33..8bb0e7e2 100644 --- a/docs/rules/a11y-link-in-text-block.md +++ b/docs/rules/a11y-link-in-text-block.md @@ -11,6 +11,7 @@ Additionally, HTML anchor elements (``) in text blocks should be converted to 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. - Any HTML `` elements detected within a text block, with an autofix to convert them to `Link` components. @@ -110,7 +111,11 @@ import {Link} from '@primer/react' function ExampleComponent() { return (

- 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 Date: Fri, 23 May 2025 10:26:26 -0400 Subject: [PATCH 4/5] Add changeset --- .changeset/odd-pumas-care.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/odd-pumas-care.md diff --git a/.changeset/odd-pumas-care.md b/.changeset/odd-pumas-care.md new file mode 100644 index 00000000..5e79848f --- /dev/null +++ b/.changeset/odd-pumas-care.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-primer-react': minor +--- + +Detect HTML anchor elements (`
`) in `a11y-link-in-text-block` rule From cb31a75ee33603c81e43326869047af0dc59a238 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 23 May 2025 10:42:25 -0400 Subject: [PATCH 5/5] Add check for `ID`(s) --- src/rules/__tests__/a11y-link-in-text-block.test.js | 7 +++++-- src/rules/a11y-link-in-text-block.js | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/rules/__tests__/a11y-link-in-text-block.test.js b/src/rules/__tests__/a11y-link-in-text-block.test.js index 95b6cc9b..12c5e1f1 100644 --- a/src/rules/__tests__/a11y-link-in-text-block.test.js +++ b/src/rules/__tests__/a11y-link-in-text-block.test.js @@ -15,11 +15,11 @@ ruleTester.run('a11y-link-in-text-block', rule, { valid: [ `import {Link} from '@primer/react'; - + Blah blah {' '} - . + . `, `import {Text, Link} from '@primer/react'; @@ -138,6 +138,9 @@ ruleTester.run('a11y-link-in-text-block', rule, { `
Link.
`, + `
+ Link. +
`, ], invalid: [ { diff --git a/src/rules/a11y-link-in-text-block.js b/src/rules/a11y-link-in-text-block.js index ccc81c58..c59b3d97 100644 --- a/src/rules/a11y-link-in-text-block.js +++ b/src/rules/a11y-link-in-text-block.js @@ -84,6 +84,10 @@ module.exports = { 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