-
Notifications
You must be signed in to change notification settings - Fork 791
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rule): identical-links-same-purpose (#1649)
- Loading branch information
Showing
18 changed files
with
1,460 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
lib/checks/navigation/identical-links-same-purpose-after.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
/** | ||
* Skip, as no results to curate | ||
*/ | ||
if (results.length < 2) { | ||
return results; | ||
} | ||
|
||
/** | ||
* Filter results for which `result` is undefined & thus `data`, `relatedNodes` are undefined | ||
*/ | ||
const incompleteResults = results.filter(({ result }) => result !== undefined); | ||
|
||
/** | ||
* for each result | ||
* - get other results with matching accessible name | ||
* - check if same purpose is served | ||
* - if not change `result` to `undefined` | ||
* - construct a list of unique results with relatedNodes to return | ||
*/ | ||
const uniqueResults = []; | ||
const nameMap = {}; | ||
|
||
for (let index = 0; index < incompleteResults.length; index++) { | ||
const currentResult = incompleteResults[index]; | ||
|
||
const { name, urlProps } = currentResult.data; | ||
/** | ||
* This is to avoid duplications in the `nodeMap` | ||
*/ | ||
if (nameMap[name]) { | ||
continue; | ||
} | ||
|
||
const sameNameResults = incompleteResults.filter( | ||
({ data }, resultNum) => data.name === name && resultNum !== index | ||
); | ||
const isSameUrl = sameNameResults.every(({ data }) => | ||
isIdenticalObject(data.urlProps, urlProps) | ||
); | ||
|
||
/** | ||
* when identical nodes exists but do not resolve to same url, flag result as `incomplete` | ||
*/ | ||
if (sameNameResults.length && !isSameUrl) { | ||
currentResult.result = undefined; | ||
} | ||
|
||
/** | ||
* -> deduplicate results (for both `pass` or `incomplete`) and add `relatedNodes` if any | ||
*/ | ||
currentResult.relatedNodes = []; | ||
currentResult.relatedNodes.push( | ||
...sameNameResults.map(node => node.relatedNodes[0]) | ||
); | ||
|
||
/** | ||
* Update `nodeMap` with `sameNameResults` | ||
*/ | ||
nameMap[name] = sameNameResults; | ||
|
||
uniqueResults.push(currentResult); | ||
} | ||
|
||
return uniqueResults; | ||
|
||
/** | ||
* Check if two given objects are the same (Note: this fn is not extensive in terms of depth equality) | ||
* @param {Object} a object a, to compare | ||
* @param {*} b object b, to compare | ||
* @returns {Boolean} | ||
*/ | ||
function isIdenticalObject(a, b) { | ||
if (!a || !b) { | ||
return false; | ||
} | ||
|
||
const aProps = Object.getOwnPropertyNames(a); | ||
const bProps = Object.getOwnPropertyNames(b); | ||
|
||
if (aProps.length !== bProps.length) { | ||
return false; | ||
} | ||
|
||
const result = aProps.every(propName => { | ||
const aValue = a[propName]; | ||
const bValue = b[propName]; | ||
|
||
if (typeof aValue !== typeof bValue) { | ||
return false; | ||
} | ||
|
||
if (typeof aValue === `object` || typeof bValue === `object`) { | ||
return isIdenticalObject(aValue, bValue); | ||
} | ||
|
||
return aValue === bValue; | ||
}); | ||
|
||
return result; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
/** | ||
* Note: `identical-links-same-purpose-after` fn, helps reconcile the results | ||
*/ | ||
const { dom, text } = axe.commons; | ||
|
||
const accText = text.accessibleTextVirtual(virtualNode); | ||
const name = text | ||
.sanitize( | ||
text.removeUnicode(accText, { | ||
emoji: true, | ||
nonBmp: true, | ||
punctuations: true | ||
}) | ||
) | ||
.toLowerCase(); | ||
|
||
if (!name) { | ||
return undefined; | ||
} | ||
|
||
/** | ||
* Set `data` and `relatedNodes` for use in `after` fn | ||
*/ | ||
const afterData = { | ||
name, | ||
urlProps: dom.urlPropsFromAttribute(node, 'href') | ||
}; | ||
this.data(afterData); | ||
this.relatedNodes([node]); | ||
|
||
return true; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
{ | ||
"id": "identical-links-same-purpose", | ||
"evaluate": "identical-links-same-purpose.js", | ||
"after": "identical-links-same-purpose-after.js", | ||
"metadata": { | ||
"impact": "minor", | ||
"messages": { | ||
"pass": "There are no other links with the same name, that go to a different URL", | ||
"incomplete": "Check that links have the same purpose, or are intentionally ambiguous." | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
/* global dom */ | ||
|
||
/** | ||
* Parse resource object for a given node from a specified attribute | ||
* @method urlPropsFromAttribute | ||
* @param {HTMLElement} node given node | ||
* @param {String} attribute attribute of the node from which resource should be parsed | ||
* @returns {Object} | ||
*/ | ||
dom.urlPropsFromAttribute = function urlPropsFromAttribute(node, attribute) { | ||
const value = node[attribute]; | ||
if (!value) { | ||
return undefined; | ||
} | ||
|
||
const nodeName = node.nodeName.toUpperCase(); | ||
let parser = node; | ||
|
||
/** | ||
* Note: | ||
* The need to create a parser, is to keep this function generic, to be able to parse resource from element like `iframe` with `src` attribute | ||
*/ | ||
if (!['A', 'AREA'].includes(nodeName)) { | ||
parser = document.createElement('a'); | ||
parser.href = value; | ||
} | ||
|
||
/** | ||
* Curate `https` and `ftps` to `http` and `ftp` as they will resolve to same resource | ||
*/ | ||
const protocol = [`https:`, `ftps:`].includes(parser.protocol) | ||
? parser.protocol.replace(/s:$/, ':') | ||
: parser.protocol; | ||
|
||
const { pathname, filename } = getPathnameOrFilename(parser.pathname); | ||
|
||
return { | ||
protocol, | ||
hostname: parser.hostname, | ||
port: getPort(parser.port), | ||
pathname: /\/$/.test(pathname) ? pathname : `${pathname}/`, | ||
search: getSearchPairs(parser.search), | ||
hash: getHashRoute(parser.hash), | ||
filename | ||
}; | ||
}; | ||
|
||
/** | ||
* Resolve given port excluding default port(s) | ||
* @param {String} port port | ||
* @returns {String} | ||
*/ | ||
function getPort(port) { | ||
const excludePorts = [ | ||
`443`, // default `https` port | ||
`80` | ||
]; | ||
return !excludePorts.includes(port) ? port : ``; | ||
} | ||
|
||
/** | ||
* Resolve if a given pathname has filename & resolve the same as parts | ||
* @method getPathnameOrFilename | ||
* @param {String} pathname pathname part of a given uri | ||
* @returns {Array<Object>} | ||
*/ | ||
function getPathnameOrFilename(pathname) { | ||
const filename = pathname.split('/').pop(); | ||
if (!filename || filename.indexOf('.') === -1) { | ||
return { | ||
pathname, | ||
filename: `` | ||
}; | ||
} | ||
|
||
return { | ||
// remove `filename` from `pathname` | ||
pathname: pathname.replace(filename, ''), | ||
|
||
// ignore filename when index.* | ||
filename: /index./.test(filename) ? `` : filename | ||
}; | ||
} | ||
|
||
/** | ||
* Parse a given query string to key/value pairs sorted alphabetically | ||
* @param {String} searchStr search string | ||
* @returns {Object} | ||
*/ | ||
function getSearchPairs(searchStr) { | ||
const query = {}; | ||
|
||
if (!searchStr || !searchStr.length) { | ||
return query; | ||
} | ||
|
||
// `substring` to remove `?` at the beginning of search string | ||
const pairs = searchStr.substring(1).split(`&`); | ||
if (!pairs || !pairs.length) { | ||
return query; | ||
} | ||
|
||
for (let index = 0; index < pairs.length; index++) { | ||
const pair = pairs[index]; | ||
const [key, value = ''] = pair.split(`=`); | ||
query[decodeURIComponent(key)] = decodeURIComponent(value); | ||
} | ||
|
||
return query; | ||
} | ||
|
||
/** | ||
* Interpret a given hash | ||
* if `hash` | ||
* -> is `hashbang` -or- `hash` is followed by `slash` | ||
* -> it resolves to a different resource | ||
* @method getHashRoute | ||
* @param {String} hash hash component of a parsed uri | ||
* @returns {String} | ||
*/ | ||
function getHashRoute(hash) { | ||
if (!hash) { | ||
return ``; | ||
} | ||
|
||
/** | ||
* Check for any conventionally-formatted hashbang that may be present | ||
* eg: `#, #/, #!, #!/` | ||
*/ | ||
const hashRegex = /#!?\/?/g; | ||
const hasMatch = hash.match(hashRegex); | ||
if (!hasMatch) { | ||
return ``; | ||
} | ||
|
||
// do not resolve inline link as hash | ||
const [matchedStr] = hasMatch; | ||
if (matchedStr === '#') { | ||
return ``; | ||
} | ||
|
||
return hash; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
const { aria, text } = axe.commons; | ||
|
||
const hasAccName = !!text.accessibleTextVirtual(virtualNode); | ||
if (!hasAccName) { | ||
return false; | ||
} | ||
|
||
const role = aria.getRole(node); | ||
if (role && role !== 'link') { | ||
return false; | ||
} | ||
|
||
return true; |
Oops, something went wrong.