From 7452a51d0bf6ac62f6c2eeb0865f08f2ddc560a2 Mon Sep 17 00:00:00 2001 From: Jey Date: Thu, 16 Aug 2018 11:14:45 +0100 Subject: [PATCH] feat(rule): Added new html-xml-lang-mismatch rule (#999) The rule checks that for the html element, there is no mismatch between the primary language in non-empty lang and xml:lang attributes, if both are used. See also - https://auto-wcag.github.io/auto-wcag/rules/SC3-1-1-html-xml-lang-match.html for further details of the rule. Closes issue: - https://github.com/dequelabs/axe-core/issues/558 ## Reviewer checks **Required fields, to be filled out by PR reviewer(s)** - [x] Follows the commit message policy, appropriate for next version - [x] Has documentation updated, a DU ticket, or requires no documentation change - [x] Includes new tests, or was unnecessary - [x] Code is reviewed for security by: @wilcofiers --- doc/rule-descriptions.md | 1 + lib/checks/language/valid-lang.js | 13 ++-- lib/checks/language/xml-lang-mismatch.js | 5 ++ lib/checks/language/xml-lang-mismatch.json | 11 +++ lib/commons/utils/get-base-lang.js | 18 +++++ lib/rules/html-xml-lang-mismatch.json | 19 +++++ lib/rules/xml-lang-mismatch-matches.js | 12 ++++ test/checks/language/xml-lang-mismatch.js | 71 +++++++++++++++++++ test/commons/utils/get-base-lang.js | 32 +++++++++ .../xml-lang-mismatch.fail.js | 30 ++++++++ .../xml-lang-mismatch.fail1.html | 22 ++++++ .../xml-lang-mismatch.fail2.html | 22 ++++++ .../xml-lang-mismatch.inapplicable.js | 26 +++++++ .../xml-lang-mismatch.inapplicable1.html | 22 ++++++ .../xml-lang-mismatch.inapplicable2.html | 22 ++++++ .../xml-lang-mismatch.pass.js | 30 ++++++++ .../xml-lang-mismatch.pass1.html | 22 ++++++ .../xml-lang-mismatch.pass2.html | 22 ++++++ .../xml-lang-mismatch.pass3.html | 22 ++++++ .../xml-lang-mismatch.pass4.html | 22 ++++++ .../xml-lang-mismatch.pass5.html | 23 ++++++ test/rule-matches/html-xml-lang-mismatch.js | 52 ++++++++++++++ 22 files changed, 510 insertions(+), 9 deletions(-) create mode 100644 lib/checks/language/xml-lang-mismatch.js create mode 100644 lib/checks/language/xml-lang-mismatch.json create mode 100644 lib/commons/utils/get-base-lang.js create mode 100644 lib/rules/html-xml-lang-mismatch.json create mode 100644 lib/rules/xml-lang-mismatch-matches.js create mode 100644 test/checks/language/xml-lang-mismatch.js create mode 100644 test/commons/utils/get-base-lang.js create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail.js create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail1.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail2.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable.js create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable1.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable2.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass.js create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass1.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass2.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass3.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass4.html create mode 100644 test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass5.html create mode 100644 test/rule-matches/html-xml-lang-mismatch.js diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 5331228f9d..1dc2ae6b39 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -31,6 +31,7 @@ | hidden-content | Informs users about hidden content. | Minor | cat.structure, experimental, review-item | true | | html-has-lang | Ensures every HTML document has a lang attribute | Serious | cat.language, wcag2a, wcag311 | true | | html-lang-valid | Ensures the lang attribute of the <html> element has a valid value | Serious | cat.language, wcag2a, wcag311 | true | +| 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 | | 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 | | image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true | | input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | diff --git a/lib/checks/language/valid-lang.js b/lib/checks/language/valid-lang.js index 0fe7945c0f..31e12b95c9 100644 --- a/lib/checks/language/valid-lang.js +++ b/lib/checks/language/valid-lang.js @@ -1,13 +1,8 @@ -function getBaseLang(lang) { - return lang - .trim() - .split('-')[0] - .toLowerCase(); -} - var langs, invalid; -langs = (options ? options : axe.commons.utils.validLangs()).map(getBaseLang); +langs = (options ? options : axe.commons.utils.validLangs()).map( + axe.commons.utils.getBaseLang +); invalid = ['lang', 'xml:lang'].reduce(function(invalid, langAttr) { var langVal = node.getAttribute(langAttr); @@ -15,7 +10,7 @@ invalid = ['lang', 'xml:lang'].reduce(function(invalid, langAttr) { return invalid; } - var baselangVal = getBaseLang(langVal); + var baselangVal = axe.commons.utils.getBaseLang(langVal); // Edge sets lang to an empty string when xml:lang is set // so we need to ignore empty strings here diff --git a/lib/checks/language/xml-lang-mismatch.js b/lib/checks/language/xml-lang-mismatch.js new file mode 100644 index 0000000000..d431a8828c --- /dev/null +++ b/lib/checks/language/xml-lang-mismatch.js @@ -0,0 +1,5 @@ +const { getBaseLang } = axe.commons.utils; +const primaryLangValue = getBaseLang(node.getAttribute('lang')); +const primaryXmlLangValue = getBaseLang(node.getAttribute('xml:lang')); + +return primaryLangValue === primaryXmlLangValue; diff --git a/lib/checks/language/xml-lang-mismatch.json b/lib/checks/language/xml-lang-mismatch.json new file mode 100644 index 0000000000..8b1ed60c3f --- /dev/null +++ b/lib/checks/language/xml-lang-mismatch.json @@ -0,0 +1,11 @@ +{ + "id": "xml-lang-mismatch", + "evaluate": "xml-lang-mismatch.js", + "metadata": { + "impact": "moderate", + "messages": { + "pass": "Lang and xml:lang attributes have the same base language", + "fail": "Lang and xml:lang attributes do not have the same base language" + } + } +} \ No newline at end of file diff --git a/lib/commons/utils/get-base-lang.js b/lib/commons/utils/get-base-lang.js new file mode 100644 index 0000000000..0b6110f42c --- /dev/null +++ b/lib/commons/utils/get-base-lang.js @@ -0,0 +1,18 @@ +/* global axe */ +/** + * Convenience function to extract primary language subtag from a given value + * @method getBaseLang + * @memberof axe.commons.utils + * @instance + * @param {String} value value specified as lang or xml:lang attribute + * @return {String} + */ +axe.utils.getBaseLang = function getBaseLang(lang) { + if (!lang) { + return ''; + } + return lang + .trim() + .split('-')[0] + .toLowerCase(); +}; diff --git a/lib/rules/html-xml-lang-mismatch.json b/lib/rules/html-xml-lang-mismatch.json new file mode 100644 index 0000000000..5befd5f4a1 --- /dev/null +++ b/lib/rules/html-xml-lang-mismatch.json @@ -0,0 +1,19 @@ +{ + "id": "html-xml-lang-mismatch", + "selector": "html[lang][xml\\:lang]", + "matches": "xml-lang-mismatch-matches.js", + "tags": [ + "cat.language", + "wcag2a", + "wcag311" + ], + "metadata": { + "description": "Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page", + "help": "HTML elements with lang and xml:lang must have the same base language" + }, + "all": [ + "xml-lang-mismatch" + ], + "any": [], + "none": [] +} \ No newline at end of file diff --git a/lib/rules/xml-lang-mismatch-matches.js b/lib/rules/xml-lang-mismatch-matches.js new file mode 100644 index 0000000000..6818db0b5a --- /dev/null +++ b/lib/rules/xml-lang-mismatch-matches.js @@ -0,0 +1,12 @@ +// using -> "selector": "html[lang][xml\\:lang]" to narrow down html with lang and xml:lang attributes + +// get primary base language for each of the attributes +const { getBaseLang } = axe.commons.utils; +const primaryLangValue = getBaseLang(node.getAttribute('lang')); +const primaryXmlLangValue = getBaseLang(node.getAttribute('xml:lang')); + +// ensure that the value specified is valid lang for both `lang` and `xml:lang` +return ( + axe.utils.validLangs().includes(primaryLangValue) && + axe.utils.validLangs().includes(primaryXmlLangValue) +); diff --git a/test/checks/language/xml-lang-mismatch.js b/test/checks/language/xml-lang-mismatch.js new file mode 100644 index 0000000000..3e6b396f5d --- /dev/null +++ b/test/checks/language/xml-lang-mismatch.js @@ -0,0 +1,71 @@ +describe('xml-lang-mismatch', function() { + 'use strict'; + + var node; + var fixture = document.getElementById('fixture'); + var checkContext = axe.testUtils.MockCheckContext(); + + beforeEach(function() { + // using a div element (instead of html), as the check is agnostic of element type + node = document.createElement('div'); + }); + + afterEach(function() { + fixture.innerHTML = ''; + checkContext.reset(); + }); + + // the rule matches filters out node of type HTML, and tests cover this scenario to ensure other elements are not allowed for this check + // hence below tests are only for HTML element, although the logic in the check looks for matches in value os lang and xml:lang + // rather than node type match - hence the check can be re-used. + + it('should return false if a only lang is supplied', function() { + node.setAttribute('lang', 'en'); + fixture.appendChild(node); + assert.isFalse( + checks['xml-lang-mismatch'].evaluate.call(checkContext, node) + ); + }); + + it('should return false if a only xml:lang is supplied albeit with region', function() { + node.setAttribute('xml:lang', 'fr-FR'); + fixture.appendChild(node); + assert.isFalse( + checks['xml-lang-mismatch'].evaluate.call(checkContext, node) + ); + }); + + it('should return false if lang is undefined', function() { + node.setAttribute('lang', undefined); + fixture.appendChild(node); + assert.isFalse( + checks['xml-lang-mismatch'].evaluate.call(checkContext, node) + ); + }); + + it('should return true if lang and xml:lang is identical', function() { + node.setAttribute('lang', 'en-GB'); + node.setAttribute('xml:lang', 'en-GB'); + fixture.appendChild(node); + assert.isTrue( + checks['xml-lang-mismatch'].evaluate.call(checkContext, node) + ); + }); + + it('should return true if lang and xml:lang have identical primary sub tag', function() { + node.setAttribute('lang', 'en-GB'); + node.setAttribute('xml:lang', 'en-US'); + fixture.appendChild(node); + assert.isTrue( + checks['xml-lang-mismatch'].evaluate.call(checkContext, node) + ); + }); + + it('should return false if lang and xml:lang are not identical', function() { + node.setAttribute('lang', 'en'); + node.setAttribute('xml:lang', 'fr-FR'); + fixture.appendChild(node); + var actual = checks['xml-lang-mismatch'].evaluate.call(checkContext, node); + assert.isFalse(actual); + }); +}); diff --git a/test/commons/utils/get-base-lang.js b/test/commons/utils/get-base-lang.js new file mode 100644 index 0000000000..7fe7607e0d --- /dev/null +++ b/test/commons/utils/get-base-lang.js @@ -0,0 +1,32 @@ +describe('utils.getBaseLang', function() { + 'use strict'; + + it('returns base lang as peanut for argument peanut-BUTTER', function() { + var actual = axe.commons.utils.getBaseLang('peanut-BUTTER'); + assert.equal(actual, 'peanut'); + }); + + it('returns base lang as fr for argument FR-CA', function() { + var actual = axe.commons.utils.getBaseLang('FR-CA'); + assert.strictEqual(actual, 'fr'); + }); + + it('returns base lang which is the prefix string before the first - (hyphen)', function() { + var actual = axe.commons.utils.getBaseLang('en-GB'); + assert.equal(actual, 'en'); + }); + + it('returns primary language subtag as base lang for multi hyphenated argument', function() { + var actual = axe.commons.utils.getBaseLang('SOME-random-lang'); + assert.strictEqual(actual, 'some'); + }); + + it('returns an empty string when argument is null or undefined', function() { + var actualNull = axe.commons.utils.getBaseLang(null); + var actualUndefined = axe.commons.utils.getBaseLang(undefined); + var actualEmpty = axe.commons.utils.getBaseLang(); + assert.strictEqual(actualNull, ''); + assert.strictEqual(actualUndefined, ''); + assert.strictEqual(actualEmpty, ''); + }); +}); diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail.js b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail.js new file mode 100644 index 0000000000..b7e56fd713 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail.js @@ -0,0 +1,30 @@ +describe('html-xml-lang-mismatch test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.run( + { + runOnly: { + type: 'rule', + values: ['html-xml-lang-mismatch'] + } + }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + + describe('violations', function() { + it('should find one', function() { + assert.lengthOf(results.violations[0].nodes, 1); + }); + + it('should find html', function() { + assert.deepEqual(results.violations[0].nodes[0].target, ['html']); + }); + }); +}); diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail1.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail1.html new file mode 100644 index 0000000000..0c1425d937 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail1.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail2.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail2.html new file mode 100644 index 0000000000..78c10461ed --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail2.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable.js b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable.js new file mode 100644 index 0000000000..441aa92984 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable.js @@ -0,0 +1,26 @@ +describe('html-xml-lang-mismatch test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.run( + { + runOnly: { + type: 'rule', + values: ['html-xml-lang-mismatch'] + } + }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + + describe('inapplicable', function() { + it('should find one', function() { + assert.lengthOf(results.inapplicable, 1); + }); + }); +}); diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable1.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable1.html new file mode 100644 index 0000000000..014a8ecf98 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable1.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable2.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable2.html new file mode 100644 index 0000000000..b168f119ef --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.inapplicable2.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass.js b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass.js new file mode 100644 index 0000000000..61cc099cf6 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass.js @@ -0,0 +1,30 @@ +describe('html-xml-lang-mismatch test', function() { + 'use strict'; + + var results; + before(function(done) { + axe.run( + { + runOnly: { + type: 'rule', + values: ['html-xml-lang-mismatch'] + } + }, + function(err, r) { + assert.isNull(err); + results = r; + done(); + } + ); + }); + + describe('passes', function() { + it('should find one', function() { + assert.lengthOf(results.passes[0].nodes, 1); + }); + + it('should find html', function() { + assert.deepEqual(results.passes[0].nodes[0].target, ['html']); + }); + }); +}); diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass1.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass1.html new file mode 100644 index 0000000000..96753f1021 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass1.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass2.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass2.html new file mode 100644 index 0000000000..b1e12d69d5 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass2.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass3.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass3.html new file mode 100644 index 0000000000..4abdbebdf5 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass3.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass4.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass4.html new file mode 100644 index 0000000000..30a5bfedb9 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass4.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass5.html b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass5.html new file mode 100644 index 0000000000..a6cd7e9d91 --- /dev/null +++ b/test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass5.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/test/rule-matches/html-xml-lang-mismatch.js b/test/rule-matches/html-xml-lang-mismatch.js new file mode 100644 index 0000000000..24408c8d11 --- /dev/null +++ b/test/rule-matches/html-xml-lang-mismatch.js @@ -0,0 +1,52 @@ +describe('xml-lang-mismatch-matches', function() { + 'use strict'; + + // nested this on a per rule basis, for future-proofing writing tests for multiple rules using the same matches + describe('for rule: html-xml-lang-mismatch', function() { + var rule; + var dom; + var fixture = document.getElementById('fixture'); + + beforeEach(function() { + rule = axe._audit.rules.find(function(rule) { + return rule.id === 'html-xml-lang-mismatch'; + }); + dom = document.createElement('html'); + }); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('is a function', function() { + var actual = rule.matches; + assert.isFunction(actual); + }); + + it('returns false if the element does not contain lang or xml:lang attribute', function() { + var actual = rule.matches(dom); + assert.isFalse(actual); + }); + + it('returns false if the element contains either/ only one of the lang or xml:lang attribute', function() { + dom.setAttribute('lang', 'nl'); + var actual = rule.matches(dom); + assert.isFalse(actual); + }); + + it('returns true if the element contains both lang and xml:lang attribute', function() { + dom.setAttribute('lang', 'en'); + dom.setAttribute('xml:lang', 'nl'); + var actual = rule.matches(dom); + assert.isTrue(actual); + }); + + it('returns false for element of type that is not HTML', function() { + var node = document.createElement('svg'); + node.setAttribute('lang', ''); + node.setAttribute('xml:lang', 'nl'); + var actual = rule.matches(node); + assert.isFalse(actual); + }); + }); +});