diff --git a/build/tasks/validate.js b/build/tasks/validate.js index 58fb1c63d..99904b618 100644 --- a/build/tasks/validate.js +++ b/build/tasks/validate.js @@ -331,6 +331,7 @@ const categories = [ 'sensory-and-visual-cues', 'structure', 'tables', + 'labels', 'text-alternatives', 'time-and-media', 'images' diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 5686c6093..0cc8dfe2c 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -5,6 +5,42 @@ import { sanitize, removeUnicode } from '../../commons/text'; +import stem from 'wink-porter2-stemmer'; + +const threshold = 0.75; + +function cleanText(str) { + return str + ?.toLowerCase() + .normalize('NFKC') + .replace(/[\u200B-\u200D\u2060\uFEFF]/g, '') + .trim(); +} + +function replaceSynonyms(text) { + const synonymMap = { + '&': 'and' + }; + return text + .split(/[^\p{L}\p{N}]+/u) + .map(word => synonymMap[word] || word) + .join(' '); +} + +function stringStemmer(str) { + return replaceSynonyms(str) + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean) + .map(word => { + const w = cleanText(word).replace(/[^\p{L}\p{N}]/gu, ''); + try { + return stem(w); + } catch (err) { + return w; + } + }) + .join(' '); +} /** * Check if a given text exists in another @@ -14,12 +50,45 @@ import { * @returns {Boolean} */ function isStringContained(compare, compareWith) { + compare = stringStemmer(compare); + compareWith = stringStemmer(compareWith); + const curatedCompareWith = curateString(compareWith); const curatedCompare = curateString(compare); if (!curatedCompareWith || !curatedCompare) { return false; } - return curatedCompareWith.includes(curatedCompare); + const res = curatedCompareWith.includes(curatedCompare); + if (res) { + return res; + } + + const tokensA = compare.split(/[^\p{L}\p{N}]+/u); + const tokensB = compareWith.split(/[^\p{L}\p{N}]+/u); + const freqA = {}, + freqB = {}; + tokensA.forEach(word => { + freqA[word] = (freqA[word] || 0) + 1; + }); + tokensB.forEach(word => { + freqB[word] = (freqB[word] || 0) + 1; + }); + + let dot = 0, + magA = 0, + magB = 0; + const allTerms = new Set([...Object.keys(freqA), ...Object.keys(freqB)]); + allTerms.forEach(term => { + const a = freqA[term] || 0; + const b = freqB[term] || 0; + dot += a * b; + magA += a * a; + magB += b * b; + }); + + const similarity = + magA && magB ? dot / (Math.sqrt(magA) * Math.sqrt(magB)) : 0; + return similarity >= threshold; // comparision with threshold as 75% } /** @@ -32,7 +101,8 @@ function curateString(str) { const noUnicodeStr = removeUnicode(str, { emoji: true, nonBmp: true, - punctuations: true + punctuations: true, + whitespace: true }); return sanitize(noUnicodeStr); } @@ -52,9 +122,11 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { subtreeDescendant: true, ignoreIconLigature: true, pixelThreshold, - occurrenceThreshold + occurrenceThreshold, + ignoreNativeTextAlternative: true // To Skip for nativeTextAlternative }) ).toLowerCase(); + if (!visibleText) { return true; } diff --git a/lib/commons/text/native-text-alternative.js b/lib/commons/text/native-text-alternative.js index 14beed82e..0d87ba601 100644 --- a/lib/commons/text/native-text-alternative.js +++ b/lib/commons/text/native-text-alternative.js @@ -10,6 +10,10 @@ import nativeTextMethods from './native-text-methods'; * @return {String} Accessible text */ export default function nativeTextAlternative(virtualNode, context = {}) { + if (context.ignoreNativeTextAlternative) { + return ''; + } + const { actualNode } = virtualNode; if ( virtualNode.props.nodeType !== 1 || diff --git a/lib/commons/text/remove-unicode.js b/lib/commons/text/remove-unicode.js index 4527cfc87..c92437f9c 100644 --- a/lib/commons/text/remove-unicode.js +++ b/lib/commons/text/remove-unicode.js @@ -20,7 +20,7 @@ import emojiRegexText from 'emoji-regex'; * @returns {String} */ function removeUnicode(str, options) { - const { emoji, nonBmp, punctuations } = options; + const { emoji, nonBmp, punctuations, whitespace } = options; if (emoji) { str = str.replace(emojiRegexText(), ''); @@ -34,6 +34,9 @@ function removeUnicode(str, options) { if (punctuations) { str = str.replace(getPunctuationRegExp(), ''); } + if (whitespace) { + str = str.replace(/\s+/g, ''); + } return str; } diff --git a/lib/commons/text/subtree-text.js b/lib/commons/text/subtree-text.js index 09fbe6a58..1325e19f8 100644 --- a/lib/commons/text/subtree-text.js +++ b/lib/commons/text/subtree-text.js @@ -59,7 +59,19 @@ function subtreeText(virtualNode, context = {}) { const phrasingElements = getElementsByContentType('phrasing').concat(['#text']); +function skipByInlineOverflow(virtualNode) { + const computedStyleOverflow = virtualNode._cache.computedStyle_overflow; + if (computedStyleOverflow && computedStyleOverflow === 'hidden') { + return true; + } + return false; +} + function appendAccessibleText(contentText, virtualNode, context) { + if (skipByInlineOverflow(virtualNode)) { + return contentText; + } + const nodeName = virtualNode.props.nodeName; let contentTextAdd = accessibleTextVirtual(virtualNode, context); if (!contentTextAdd) { diff --git a/lib/rules/autocomplete-valid.json b/lib/rules/autocomplete-valid.json index 9afefdcd5..9a6eebd02 100644 --- a/lib/rules/autocomplete-valid.json +++ b/lib/rules/autocomplete-valid.json @@ -13,7 +13,7 @@ ], "actIds": ["73f2c2"], "metadata": { - "description": "Ensure that the necessary form fields use the autocomplete attribute with a valid input.", + "description": "Ensures that the necessary form fields use the autocomplete attribute with a valid input.", "help": "Autocomplete attribute must have a valid value" }, "all": ["autocomplete-valid"], diff --git a/lib/rules/heading-order-bp.json b/lib/rules/heading-order-bp.json new file mode 100644 index 000000000..4b0860fb6 --- /dev/null +++ b/lib/rules/heading-order-bp.json @@ -0,0 +1,19 @@ +{ + "id": "heading-order-bp", + "impact": "moderate", + "selector": "h1, h2, h3, h4, h5, h6, [role=heading]", + "matches": "heading-matches", + "tags": [ + "cat.structure", + "best-practice", + "a11y-engine", + "a11y-engine-experimental" + ], + "metadata": { + "description": "Ensures the order of headings is semantically correct", + "help": "Heading levels should only increase by one" + }, + "all": [], + "any": ["heading-order"], + "none": [] +} diff --git a/package-lock.json b/package-lock.json index 07ef31c73..287a42e7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,8 @@ "typescript": "^5.2.2", "uglify-js": "^3.17.4", "wcag-act-rules": "github:w3c/wcag-act-rules#dc90495a5533d326b300ee5a9487afdfc6d493c0", - "weakmap-polyfill": "^2.0.4" + "weakmap-polyfill": "^2.0.4", + "wink-porter2-stemmer": "^2.0.1" }, "engines": { "node": ">=4" @@ -13129,6 +13130,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wink-porter2-stemmer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wink-porter2-stemmer/-/wink-porter2-stemmer-2.0.1.tgz", + "integrity": "sha512-0g+RkkqhRXFmSpJQStVXW5N/WsshWpJXsoDRW7DwVkGI2uDT6IBCoq3xdH5p6IHLaC6ygk7RWUsUx4alKxoagQ==", + "dev": true + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -23215,6 +23222,12 @@ "is-symbol": "^1.0.3" } }, + "wink-porter2-stemmer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wink-porter2-stemmer/-/wink-porter2-stemmer-2.0.1.tgz", + "integrity": "sha512-0g+RkkqhRXFmSpJQStVXW5N/WsshWpJXsoDRW7DwVkGI2uDT6IBCoq3xdH5p6IHLaC6ygk7RWUsUx4alKxoagQ==", + "dev": true + }, "wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index a6bac075b..5b2177ace 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,8 @@ "typescript": "^5.2.2", "uglify-js": "^3.17.4", "wcag-act-rules": "github:w3c/wcag-act-rules#dc90495a5533d326b300ee5a9487afdfc6d493c0", - "weakmap-polyfill": "^2.0.4" + "weakmap-polyfill": "^2.0.4", + "wink-porter2-stemmer": "^2.0.1" }, "lint-staged": { "*.{md,json,ts,html}": [