Skip to content

Commit

Permalink
feat(rule): Added new html-xml-lang-mismatch rule (#999)
Browse files Browse the repository at this point in the history
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:
- #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
  • Loading branch information
jeeyyy authored and WilcoFiers committed Aug 16, 2018
1 parent 9ff5d54 commit 7452a51
Show file tree
Hide file tree
Showing 22 changed files with 510 additions and 9 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 4 additions & 9 deletions lib/checks/language/valid-lang.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
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);
if (typeof langVal !== 'string') {
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
Expand Down
5 changes: 5 additions & 0 deletions lib/checks/language/xml-lang-mismatch.js
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions lib/checks/language/xml-lang-mismatch.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
18 changes: 18 additions & 0 deletions lib/commons/utils/get-base-lang.js
Original file line number Diff line number Diff line change
@@ -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();
};
19 changes: 19 additions & 0 deletions lib/rules/html-xml-lang-mismatch.json
Original file line number Diff line number Diff line change
@@ -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": []
}
12 changes: 12 additions & 0 deletions lib/rules/xml-lang-mismatch-matches.js
Original file line number Diff line number Diff line change
@@ -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)
);
71 changes: 71 additions & 0 deletions test/checks/language/xml-lang-mismatch.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions test/commons/utils/get-base-lang.js
Original file line number Diff line number Diff line change
@@ -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, '');
});
});
30 changes: 30 additions & 0 deletions test/integration/full/xml-lang-mismatch/xml-lang-mismatch.fail.js
Original file line number Diff line number Diff line change
@@ -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']);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr" xml:lang="en">
<head>
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
<script src="/node_modules/mocha/mocha.js"></script>
<script src="/node_modules/chai/chai.js"></script>
<script src="/axe.js"></script>
<script>
mocha.setup({
timeout: 10000,
ui: 'bdd'
});
var assert = chai.assert;
</script>
</head>
<body>
<div id="mocha" role="complementary"></div>
<script src="/test/testutils.js"></script>
<script src="xml-lang-mismatch.fail.js"></script>
<script src="/test/integration/adapter.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr-CA" xml:lang="en-CA">
<head>
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
<script src="/node_modules/mocha/mocha.js"></script>
<script src="/node_modules/chai/chai.js"></script>
<script src="/axe.js"></script>
<script>
mocha.setup({
timeout: 10000,
ui: 'bdd'
});
var assert = chai.assert;
</script>
</head>
<body>
<div id="mocha" role="complementary"></div>
<script src="/test/testutils.js"></script>
<script src="xml-lang-mismatch.fail.js"></script>
<script src="/test/integration/adapter.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr" xml:lang="">
<head>
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
<script src="/node_modules/mocha/mocha.js"></script>
<script src="/node_modules/chai/chai.js"></script>
<script src="/axe.js"></script>
<script>
mocha.setup({
timeout: 10000,
ui: 'bdd'
});
var assert = chai.assert;
</script>
</head>
<body>
<div id="mocha" role="complementary"></div>
<script src="/test/testutils.js"></script>
<script src="xml-lang-mismatch.inapplicable.js"></script>
<script src="/test/integration/adapter.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="" xml:lang="">
<head>
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
<script src="/node_modules/mocha/mocha.js"></script>
<script src="/node_modules/chai/chai.js"></script>
<script src="/axe.js"></script>
<script>
mocha.setup({
timeout: 10000,
ui: 'bdd'
});
var assert = chai.assert;
</script>
</head>
<body>
<div id="mocha" role="complementary"></div>
<script src="/test/testutils.js"></script>
<script src="xml-lang-mismatch.inapplicable.js"></script>
<script src="/test/integration/adapter.js"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions test/integration/full/xml-lang-mismatch/xml-lang-mismatch.pass.js
Original file line number Diff line number Diff line change
@@ -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']);
});
});
});
Loading

1 comment on commit 7452a51

@jeankaplansky
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://dequeuniversity.com/rules/axe/3.1/html-xml-lang-mismatch page created.

TBD - update rule help language to explain the relationship between the lang and xml:lang attributes, and why the value of these attributes must be identical.

Please sign in to comment.