Skip to content

Commit

Permalink
feat(rule): identical-links-same-purpose (#1649)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeeyyy committed Jan 15, 2020
1 parent 6e4ed6b commit 9c73f62
Show file tree
Hide file tree
Showing 18 changed files with 1,460 additions and 2 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Expand Up @@ -42,6 +42,7 @@
| html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | true | false |
| html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | true | false |
| html-xml-lang-mismatch | Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page | Moderate | cat.language, wcag2a, wcag311 | true | true | false |
| identical-links-same-purpose | Ensure that links with the same accessible name serve a similar purpose | Minor | wcag2aaa, wcag249, best-practice | true | false | true |
| image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | true | false |
| image-redundant-alt | Ensure image alternative is not repeated as text | Minor | cat.text-alternatives, best-practice | true | true | false |
| input-button-name | Ensures input buttons have discernible text | Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true | true | false |
Expand Down
100 changes: 100 additions & 0 deletions lib/checks/navigation/identical-links-same-purpose-after.js
@@ -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;
}
31 changes: 31 additions & 0 deletions lib/checks/navigation/identical-links-same-purpose.js
@@ -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;
12 changes: 12 additions & 0 deletions lib/checks/navigation/identical-links-same-purpose.json
@@ -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."
}
}
}
59 changes: 57 additions & 2 deletions lib/commons/dom/is-visible.js
Expand Up @@ -39,6 +39,50 @@ function isClipped(style) {
return false;
}

/**
* Check `AREA` element is visible
* - validate if it is a child of `map`
* - ensure `map` is referred by `img` using the `usemap` attribute
* @param {Element} areaEl `AREA` element
* @retruns {Boolean}
*/
function isAreaVisible(el, screenReader, recursed) {
/**
* Note:
* - Verified that `map` element cannot refer to `area` elements across different document trees
* - Verified that `map` element does not get affected by altering `display` property
*/
const mapEl = dom.findUp(el, 'map');
if (!mapEl) {
return false;
}

const mapElName = mapEl.getAttribute('name');
if (!mapElName) {
return false;
}

/**
* `map` element has to be in light DOM
*/
const mapElRootNode = dom.getRootNode(el);
if (!mapElRootNode || mapElRootNode.nodeType !== 9) {
return false;
}

const refs = axe.utils.querySelectorAll(
axe._tree,
`img[usemap="#${axe.utils.escapeSelector(mapElName)}"]`
);
if (!refs || !refs.length) {
return false;
}

return refs.some(({ actualNode }) =>
dom.isVisible(actualNode, screenReader, recursed)
);
}

/**
* Determine whether an element is visible
* @method isVisible
Expand Down Expand Up @@ -74,9 +118,13 @@ dom.isVisible = function(el, screenReader, recursed) {
}

const nodeName = el.nodeName.toUpperCase();

if (
style.getPropertyValue('display') === 'none' ||
/**
* Note:
* Firefox's user-agent always sets `AREA` element to `display:none`
* hence excluding the edge case, for visibility computation
*/
(nodeName !== 'AREA' && style.getPropertyValue('display') === 'none') ||
['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(nodeName) ||
(!screenReader && isClipped(style)) ||
(!recursed &&
Expand All @@ -89,6 +137,13 @@ dom.isVisible = function(el, screenReader, recursed) {
return false;
}

/**
* check visibility of `AREA`
*/
if (nodeName === 'AREA') {
return isAreaVisible(el, screenReader, recursed);
}

const parent = el.assignedSlot ? el.assignedSlot : el.parentNode;
let isVisible = false;
if (parent) {
Expand Down
143 changes: 143 additions & 0 deletions lib/commons/dom/url-props-from-attribute.js
@@ -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;
}
13 changes: 13 additions & 0 deletions lib/rules/identical-links-same-purpose-matches.js
@@ -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;

0 comments on commit 9c73f62

Please sign in to comment.