diff --git a/lib/rules/a11y-no-generic-link-text.js b/lib/rules/a11y-no-generic-link-text.js
index a262b43e..8c98d19d 100644
--- a/lib/rules/a11y-no-generic-link-text.js
+++ b/lib/rules/a11y-no-generic-link-text.js
@@ -1,4 +1,5 @@
-const {elementType, getProp, getPropValue} = require('jsx-ast-utils')
+const {getProp, getPropValue} = require('jsx-ast-utils')
+const {getElementType} = require('../utils/get-element-type')
const bannedLinkText = ['read more', 'here', 'click here', 'learn more', 'more', 'here']
@@ -23,7 +24,9 @@ module.exports = {
create(context) {
return {
JSXOpeningElement: node => {
- if (elementType(node) !== 'a') return
+ const elementType = getElementType(context, node)
+
+ if (elementType !== 'a') return
if (getProp(node.attributes, 'aria-labelledby')) return
let cleanTextContent // text content we can reliably fetch
diff --git a/lib/utils/get-element-type.js b/lib/utils/get-element-type.js
new file mode 100644
index 00000000..60ce6f68
--- /dev/null
+++ b/lib/utils/get-element-type.js
@@ -0,0 +1,36 @@
+const {elementType, getProp, getPropValue} = require('jsx-ast-utils')
+
+/*
+Allows custom component to be mapped to an element type.
+When a default is set, all instances of the component will be mapped to the default.
+If a prop determines the type, it can be specified with `props`.
+
+For now, we only support the mapping of one prop type to an element type, rather than combinations of props.
+*/
+function getElementType(context, node) {
+ const {settings} = context
+ const rawElement = elementType(node)
+ if (!settings) return rawElement
+
+ const componentMap = settings.github && settings.github.components
+ if (!componentMap) return rawElement
+ const component = componentMap[rawElement]
+ if (!component) return rawElement
+ let element = component.default ? component.default : rawElement
+
+ if (component.props) {
+ const props = Object.entries(component.props)
+ for (const [key, value] of props) {
+ const propMap = value
+ const propValue = getPropValue(getProp(node.attributes, key))
+ const mapValue = propMap[propValue]
+
+ if (mapValue) {
+ element = mapValue
+ }
+ }
+ }
+ return element
+}
+
+module.exports = {getElementType}
diff --git a/package-lock.json b/package-lock.json
index ccab3919..5290290f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
},
"devDependencies": {
"@github/prettier-config": "0.0.4",
+ "chai": "^4.3.6",
"eslint": "^8.0.1",
"eslint-plugin-eslint-plugin": "^5.0.0",
"mocha": "^10.0.0"
@@ -551,6 +552,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
@@ -683,6 +693,24 @@
}
]
},
+ "node_modules/chai": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz",
+ "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==",
+ "dev": true,
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.2",
+ "deep-eql": "^3.0.1",
+ "get-func-name": "^2.0.0",
+ "loupe": "^2.3.1",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.0.5"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -698,6 +726,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/check-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+ "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -848,6 +885,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/deep-eql": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
+ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+ "dev": true,
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1605,6 +1654,15 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-func-name": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+ "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@@ -2165,6 +2223,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/loupe": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz",
+ "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==",
+ "dev": true,
+ "dependencies": {
+ "get-func-name": "^2.0.0"
+ }
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -2552,6 +2619,15 @@
"node": ">=8"
}
},
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -2986,6 +3062,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -3566,6 +3651,12 @@
"es-abstract": "^1.19.0"
}
},
+ "assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true
+ },
"ast-types-flow": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
@@ -3651,6 +3742,21 @@
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001363.tgz",
"integrity": "sha512-HpQhpzTGGPVMnCjIomjt+jvyUu8vNFo3TaDiZ/RcoTrlOq/5+tC8zHdsbgFB6MxmaY+jCpsH09aD80Bb4Ow3Sg=="
},
+ "chai": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz",
+ "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==",
+ "dev": true,
+ "requires": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.2",
+ "deep-eql": "^3.0.1",
+ "get-func-name": "^2.0.0",
+ "loupe": "^2.3.1",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.0.5"
+ }
+ },
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3660,6 +3766,12 @@
"supports-color": "^7.1.0"
}
},
+ "check-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+ "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+ "dev": true
+ },
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -3769,6 +3881,15 @@
"integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
"dev": true
},
+ "deep-eql": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
+ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+ "dev": true,
+ "requires": {
+ "type-detect": "^4.0.0"
+ }
+ },
"deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4342,6 +4463,12 @@
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
},
+ "get-func-name": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+ "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+ "dev": true
+ },
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@@ -4728,6 +4855,15 @@
"is-unicode-supported": "^0.1.0"
}
},
+ "loupe": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz",
+ "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==",
+ "dev": true,
+ "requires": {
+ "get-func-name": "^2.0.0"
+ }
+ },
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -5004,6 +5140,12 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
},
+ "pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true
+ },
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -5274,6 +5416,12 @@
"prelude-ls": "^1.2.1"
}
},
+ "type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true
+ },
"type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
diff --git a/package.json b/package.json
index 29837104..65564edc 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"scripts": {
"pretest": "mkdir -p node_modules/ && ln -fs $(pwd) node_modules/",
"eslint-check": "eslint-config-prettier .eslintrc.js",
- "test": "npm run eslint-check && eslint . && mocha tests/"
+ "test": "npm run eslint-check && eslint . && mocha tests/**/*.js tests/"
},
"repository": {
"type": "git",
@@ -51,6 +51,7 @@
],
"devDependencies": {
"@github/prettier-config": "0.0.4",
+ "chai": "^4.3.6",
"eslint": "^8.0.1",
"eslint-plugin-eslint-plugin": "^5.0.0",
"mocha": "^10.0.0"
diff --git a/tests/a11y-no-generic-link-text.js b/tests/a11y-no-generic-link-text.js
index 7ef4a7b4..39eeda45 100644
--- a/tests/a11y-no-generic-link-text.js
+++ b/tests/a11y-no-generic-link-text.js
@@ -19,9 +19,65 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
{code: "GitHub Home;"},
{code: "GitHub Home;"},
{code: "Read more;"},
- {code: "Read more;"}
+ {code: "Read more;"},
+ {code: 'Read more;'},
+ {
+ code: 'Read more',
+ settings: {
+ github: {
+ components: {
+ Link: {
+ props: {as: {undefined: 'a'}}
+ }
+ }
+ }
+ }
+ }
],
invalid: [
+ {
+ code: 'Read more',
+ errors: [{message: errorMessage}],
+ settings: {
+ github: {
+ components: {
+ ButtonLink: {
+ default: 'a'
+ }
+ }
+ }
+ }
+ },
+ {
+ code: 'Read more',
+ errors: [{message: errorMessage}],
+ settings: {
+ github: {
+ components: {
+ Link: {
+ props: {as: {undefined: 'a'}}
+ }
+ }
+ }
+ }
+ },
+ {
+ code: 'Read more',
+ errors: [{message: errorMessage}],
+ settings: {
+ github: {
+ components: {
+ Test: {
+ props: {as: {a: 'a'}}
+ }
+ }
+ }
+ }
+ },
+ {
+ code: "Click here;",
+ errors: [{message: errorMessage}]
+ },
{code: 'Click here*;', errors: [{message: errorMessage}]},
{code: 'Learn more.;', errors: [{message: errorMessage}]},
{code: ";", errors: [{message: errorMessage}]},
diff --git a/tests/utils/get-element-type.js b/tests/utils/get-element-type.js
new file mode 100644
index 00000000..47115550
--- /dev/null
+++ b/tests/utils/get-element-type.js
@@ -0,0 +1,108 @@
+const {getElementType} = require('../../lib/utils/get-element-type')
+const mocha = require('mocha')
+const describe = mocha.describe
+const it = mocha.it
+const expect = require('chai').expect
+
+function mockJSXAttribute(prop, propValue) {
+ return {
+ type: 'JSXAttribute',
+ name: {
+ type: 'JSXIdentifier',
+ name: prop
+ },
+ value: {
+ type: 'Literal',
+ value: propValue
+ }
+ }
+}
+
+function mockJSXOpeningElement(tagName, attributes = []) {
+ return {
+ type: 'JSXOpeningElement',
+ name: {
+ type: 'JSXIdentifier',
+ name: tagName
+ },
+ attributes
+ }
+}
+
+function mockSetting(componentSetting = {}) {
+ return {
+ settings: {
+ github: {
+ components: componentSetting
+ }
+ }
+ }
+}
+
+describe('getElementType', function () {
+ it('returns raw element type', function () {
+ const node = mockJSXOpeningElement('a')
+ expect(getElementType({}, node)).to.equal('a')
+ })
+
+ it('returns element type from default if set', function () {
+ const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')])
+ const setting = mockSetting({
+ Link: {
+ default: 'button'
+ }
+ })
+ expect(getElementType(setting, node)).to.equal('button')
+ })
+
+ it('returns element type from matching props setting if set', function () {
+ const setting = mockSetting({
+ Link: {
+ default: 'a',
+ props: {
+ as: {summary: 'summary'}
+ }
+ }
+ })
+
+ const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')])
+ expect(getElementType(setting, node)).to.equal('summary')
+ })
+
+ it('returns raw type if no default or matching prop setting', function () {
+ const setting = mockSetting({
+ Link: {
+ props: {
+ as: {summary: 'summary'}
+ }
+ }
+ })
+ const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')])
+ expect(getElementType(setting, node)).to.equal('Link')
+ })
+
+ it('allows undefined prop to be mapped to a type', function () {
+ const setting = mockSetting({
+ Link: {
+ props: {
+ as: {undefined: 'a'}
+ }
+ }
+ })
+ const node = mockJSXOpeningElement('Link')
+ expect(getElementType(setting, node)).to.equal('a')
+ })
+
+ it('returns raw type if prop does not match props setting and no default type', function () {
+ const setting = mockSetting({
+ Link: {
+ props: {
+ as: {undefined: 'a'}
+ }
+ }
+ })
+
+ const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')])
+ expect(getElementType(setting, node)).to.equal('Link')
+ })
+})