diff --git a/README.md b/README.md index 50cb83f03f..e3f0b6bc11 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,32 @@ To create a new translation for aXe, start by running `grunt translate --lang=`. This will add new messages used in English and remove messages which were not used in English. +Additionally, locale can be applied at runtime by passing a `locale` object to `axe.configure()`. The locale object must be of the same shape as existing locales in the `./locales` directory. For example: + +```js +axe.configure({ + locale: { + lang: 'de', + rules: { + accesskeys: { + help: 'Der Wert des accesskey-Attributes muss einzigartig sein.' + }, + // ... + }, + checks: { + abstractrole: { + fail: 'Abstrakte ARIA-Rollen dürfen nicht direkt verwendet werden.' + }, + 'aria-errormessage': { + // Note: doT (https://github.com/olado/dot) templates are supported here. + fail: 'Der Wert der aria-errormessage {{~it.data:value}} `{{=value}}{{~}}` muss eine Technik verwenden, um die Message anzukündigen (z. B., aria-live, aria-describedby, role=alert, etc.).' + } + // ... + } + } +}) +``` + ## Supported ARIA Roles and Attributes. Refer [aXe ARIA support](./doc/aria-supported.md) for a complete list of ARIA supported roles and attributes by axe. diff --git a/axe.d.ts b/axe.d.ts index 89d095b82b..7f7eea973a 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -71,6 +71,24 @@ declare namespace axe { target: string[]; html: string; } + interface RuleLocale { + [key: string]: { + description: string; + help: string; + }; + } + interface CheckLocale { + [key: string]: { + pass: string; + fail: string; + incomplete: string | { [key: string]: string }; + }; + } + interface Locale { + lang?: string; + rules?: RuleLocale; + checks?: CheckLocale; + } interface Spec { branding?: { brand: string; @@ -79,6 +97,7 @@ declare namespace axe { reporter?: ReporterVersion; checks?: Check[]; rules?: Rule[]; + locale?: Locale; } interface Check { id: string; diff --git a/doc/API.md b/doc/API.md index 9ceed246a6..f734c92658 100644 --- a/doc/API.md +++ b/doc/API.md @@ -149,7 +149,9 @@ axe.configure({ }, reporter: "option", checks: [Object], - rules: [Object]}); + rules: [Object], + locale: Object +}); ``` #### Parameters @@ -183,6 +185,7 @@ axe.configure({ * `tags` - array(optional, default `[]`). A list if the tags that "classify" the rule. In practice, you must supply some valid tags or the default evaluation will not invoke the rule. The convention is to include the standard (WCAG 2 and/or section 508), the WCAG 2 level, Section 508 paragraph, and the WCAG 2 success criteria. Tags are constructed by converting all letters to lower case, removing spaces and periods and concatinating the result. E.g. WCAG 2 A success criteria 1.1.1 would become ["wcag2a", "wcag111"] * `matches` - string(optional, default `*`). A filtering CSS selector that will exclude elements that do not match the CSS selector. * `disableOtherRules` - Disables all rules not included in the `rules` property. + * `locale` - A locale object to apply (at runtime) to all rules and checks, in the same shape as `/locales/*.json`. **Returns:** Nothing diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index d1c934855e..cbb9eebb05 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -40,8 +40,191 @@ function Audit(audit) { this.defaultConfig = audit; this._init(); + + // A copy of the "default" locale. This will be set if the user + // provides a new locale to `axe.configure()` and used to undo + // changes in `axe.reset()`. + this._defaultLocale = null; } +/** + * Build and set the previous locale. Will noop if a previous + * locale was already set, as we want the ability to "reset" + * to the default ("first") configuration. + */ + +Audit.prototype._setDefaultLocale = function() { + if (this._defaultLocale) { + return; + } + + const locale = { + checks: {}, + rules: {} + }; + + // XXX: unable to use `for-of` here, as doing so would + // require us to polyfill `Symbol`. + const checkIDs = Object.keys(this.data.checks); + for (let i = 0; i < checkIDs.length; i++) { + const id = checkIDs[i]; + const check = this.data.checks[id]; + const { pass, fail, incomplete } = check.messages; + locale.checks[id] = { + pass, + fail, + incomplete + }; + } + + const ruleIDs = Object.keys(this.data.rules); + for (let i = 0; i < ruleIDs.length; i++) { + const id = ruleIDs[i]; + const rule = this.data.rules[id]; + const { description, help } = rule; + locale.rules[id] = { description, help }; + } + + this._defaultLocale = locale; +}; + +/** + * Reset the locale to the "default". + */ + +Audit.prototype._resetLocale = function() { + // If the default locale has not already been set, we can exit early. + const defaultLocale = this._defaultLocale; + if (!defaultLocale) { + return; + } + + // Apply the default locale + this.applyLocale(defaultLocale); +}; + +/** + * Merge two check locales (a, b), favoring `b`. + * + * Both locale `a` and the returned shape resemble: + * + * { + * impact: string, + * messages: { + * pass: string | function, + * fail: string | function, + * incomplete: string | { + * [key: string]: string | function + * } + * } + * } + * + * Locale `b` follows the `axe.CheckLocale` shape and resembles: + * + * { + * pass: string, + * fail: string, + * incomplete: string | { [key: string]: string } + * } + */ + +const mergeCheckLocale = (a, b) => { + let { pass, fail } = b; + // If the message(s) are Strings, they have not yet been run + // thru doT (which will return a Function). + if (typeof pass === 'string') { + pass = axe.imports.doT.compile(pass); + } + if (typeof fail === 'string') { + fail = axe.imports.doT.compile(fail); + } + return { + ...a, + messages: { + pass: pass || a.messages.pass, + fail: fail || a.messages.fail, + incomplete: + typeof a.messages.incomplete === 'object' + ? // TODO: for compleness-sake, we should be running + // incomplete messages thru doT as well. This was + // out-of-scope for runtime localization, but should + // eventually be addressed. + { ...a.messages.incomplete, ...b.incomplete } + : b.incomplete + } + }; +}; + +/** + * Merge two rule locales (a, b), favoring `b`. + */ + +const mergeRuleLocale = (a, b) => { + let { help, description } = b; + // If the message(s) are Strings, they have not yet been run + // thru doT (which will return a Function). + if (typeof help === 'string') { + help = axe.imports.doT.compile(help); + } + if (typeof description === 'string') { + description = axe.imports.doT.compile(description); + } + return { + ...a, + help: help || a.help, + description: description || a.description + }; +}; + +/** + * Apply locale for the given `checks`. + */ + +Audit.prototype._applyCheckLocale = function(checks) { + const keys = Object.keys(checks); + for (let i = 0; i < keys.length; i++) { + const id = keys[i]; + if (!this.data.checks[id]) { + throw new Error(`Locale provided for unknown check: "${id}"`); + } + + this.data.checks[id] = mergeCheckLocale(this.data.checks[id], checks[id]); + } +}; + +/** + * Apply locale for the given `rules`. + */ + +Audit.prototype._applyRuleLocale = function(rules) { + const keys = Object.keys(rules); + for (let i = 0; i < keys.length; i++) { + const id = keys[i]; + if (!this.data.rules[id]) { + throw new Error(`Locale provided for unknown rule: "${id}"`); + } + this.data.rules[id] = mergeRuleLocale(this.data.rules[id], rules[id]); + } +}; + +/** + * Apply the given `locale`. + * + * @param {axe.Locale} + */ + +Audit.prototype.applyLocale = function(locale) { + this._setDefaultLocale(); + + if (locale.checks) { + this._applyCheckLocale(locale.checks); + } + + if (locale.rules) { + this._applyRuleLocale(locale.rules); + } +}; + /** * Initializes the rules and checks */ @@ -367,4 +550,5 @@ Audit.prototype._constructHelpUrls = function(previous = null) { Audit.prototype.resetRulesAndChecks = function() { 'use strict'; this._init(); + this._resetLocale(); }; diff --git a/lib/core/public/configure.js b/lib/core/public/configure.js index e3eb753e69..029a9b2ba2 100644 --- a/lib/core/public/configure.js +++ b/lib/core/public/configure.js @@ -1,6 +1,6 @@ /* global reporters */ function configureChecksRulesAndBranding(spec) { - /*eslint max-statements: ["error",20]*/ + /*eslint max-statements: ["error",21]*/ 'use strict'; var audit; @@ -47,6 +47,11 @@ function configureChecksRulesAndBranding(spec) { if (spec.tagExclude) { audit.tagExclude = spec.tagExclude; } + + // Support runtime localization + if (spec.locale) { + audit.applyLocale(spec.locale); + } } axe.configure = configureChecksRulesAndBranding; diff --git a/test/core/public/configure.js b/test/core/public/configure.js index 15de4e435e..8b684d4daa 100644 --- a/test/core/public/configure.js +++ b/test/core/public/configure.js @@ -239,4 +239,320 @@ describe('axe.configure', function() { assert.equal(axe._audit.rules[3].id, 'black-panther'); assert.equal(axe._audit.rules[3].enabled, true); }); + + describe('given a locale object', function() { + beforeEach(function() { + axe._load({}); + + axe.configure({ + rules: [ + { + id: 'greeting', + selector: 'div', + excludeHidden: false, + tags: ['foo', 'bar'], + metadata: { + description: 'This is a rule that rules', + help: 'ABCDEFGHIKLMNOPQRSTVXYZ' + } + } + ], + checks: [ + { + id: 'banana', + evaluate: function() {}, + metadata: { + impact: 'srsly serious', + messages: { + pass: 'yay', + fail: 'boo', + incomplete: { + foo: 'a', + bar: 'b', + baz: 'c' + } + } + } + } + ] + }); + }); + + it('should update check and rule metadata', function() { + axe.configure({ + locale: { + lang: 'lol', + rules: { + greeting: { + description: 'hello', + help: 'hi' + } + }, + checks: { + banana: { + pass: 'pizza', + fail: 'icecream', + incomplete: { + foo: 'meat', + bar: 'fruit', + baz: 'vegetables' + } + } + } + } + }); + + var audit = axe._audit; + var localeData = audit.data; + + assert.equal(localeData.rules.greeting.help(), 'hi'); + assert.equal(localeData.rules.greeting.description(), 'hello'); + assert.equal(localeData.checks.banana.messages.pass(), 'pizza'); + assert.equal(localeData.checks.banana.messages.fail(), 'icecream'); + assert.deepEqual(localeData.checks.banana.messages.incomplete, { + foo: 'meat', + bar: 'fruit', + baz: 'vegetables' + }); + }); + + it('should merge locales (favoring "new")', function() { + axe.configure({ + locale: { + lang: 'lol', + rules: { greeting: { description: 'hello' } }, + checks: { + banana: { + fail: 'icecream' + } + } + } + }); + + var audit = axe._audit; + var localeData = audit.data; + + assert.equal(localeData.rules.greeting.help, 'ABCDEFGHIKLMNOPQRSTVXYZ'); + assert.equal(localeData.rules.greeting.description(), 'hello'); + assert.equal(localeData.checks.banana.messages.pass, 'yay'); + assert.equal(localeData.checks.banana.messages.fail(), 'icecream'); + assert.deepEqual(localeData.checks.banana.messages.incomplete, { + foo: 'a', + bar: 'b', + baz: 'c' + }); + }); + + describe('only given checks', function() { + it('should not error', function() { + assert.doesNotThrow(function() { + axe.configure({ + locale: { + lang: 'lol', + checks: { + banana: { + fail: 'icecream', + incomplete: { + baz: 'vegetables' + } + } + } + } + }); + }); + }); + }); + + describe('only given rules', function() { + it('should not error', function() { + assert.doesNotThrow(function() { + axe.configure({ + locale: { + rules: { greeting: { help: 'foo', description: 'bar' } } + } + }); + }); + }); + }); + + describe('check incomplete messages', function() { + beforeEach(function() { + axe.configure({ + checks: [ + { + id: 'panda', + evaluate: function() {}, + metadata: { + impact: 'yep', + messages: { + pass: 'p', + fail: 'f', + incomplete: 'i' + } + } + } + ] + }); + }); + + it('should support strings', function() { + axe.configure({ + locale: { + checks: { + panda: { + incomplete: 'radio' + } + } + } + }); + + assert.equal(axe._audit.data.checks.panda.messages.incomplete, 'radio'); + }); + + it('should shallow-merge objects', function() { + axe.configure({ + locale: { + lang: 'lol', + checks: { + banana: { + incomplete: { + baz: 'vegetables' + } + } + } + } + }); + + assert.deepEqual(axe._audit.data.checks.banana.messages.incomplete, { + foo: 'a', + bar: 'b', + baz: 'vegetables' + }); + }); + }); + + // This test ensures we do not drop additional properties added to + // checks. See https://github.com/dequelabs/axe-core/pull/1036/files#r207738673 + // for reasoning. + it('should keep existing properties on check data', function() { + axe.configure({ + checks: [ + { + id: 'banana', + metadata: { + impact: 'potato', + foo: 'bar', + messages: { + pass: 'pass', + fail: 'fail', + incomplete: 'incomplete' + } + } + } + ] + }); + + axe.configure({ + locale: { + lang: 'lol', + checks: { + banana: { + pass: 'yay banana' + } + } + } + }); + + var banana = axe._audit.data.checks.banana; + assert.equal(banana.impact, 'potato'); + assert.equal(banana.foo, 'bar'); + assert.equal(banana.messages.pass(), 'yay banana'); + }); + + it('should error when provided an unknown rule id', function() { + assert.throws(function() { + axe.configure({ + locale: { + rules: { nope: { help: 'helpme' } } + } + }); + }, /unknown rule: "nope"/); + }); + + it('should error when provided an unknown check id', function() { + assert.throws(function() { + axe.configure({ + locale: { + checks: { nope: { pass: 'helpme' } } + } + }); + }, /unknown check: "nope"/); + }); + + it('should set default locale', function() { + assert.isNull(axe._audit._defaultLocale); + axe.configure({ + locale: { + lang: 'lol', + checks: { + banana: { + pass: 'yay banana' + } + } + } + }); + assert.ok(axe._audit._defaultLocale); + }); + + describe('also given metadata', function() { + it('should favor the locale', function() { + axe.configure({ + locale: { + lang: 'lol', + rules: { + greeting: { + help: 'hi' + } + } + }, + rules: [ + { + id: 'greeting', + metadata: { + help: 'potato' + } + } + ] + }); + + var audit = axe._audit; + var localeData = audit.data; + + assert.equal(localeData.rules.greeting.help(), 'hi'); + }); + }); + + describe('after locale has been set', function() { + describe('the provided messages', function() { + it('should allow for doT templating', function() { + axe.configure({ + locale: { + lang: 'foo', + rules: { + greeting: { + help: 'foo: {{=it.data}}.' + } + } + } + }); + + var greeting = axe._audit.data.rules.greeting; + var value = greeting.help({ + data: 'bar' + }); + assert.equal(value, 'foo: bar.'); + }); + }); + }); + }); }); diff --git a/test/core/public/reset.js b/test/core/public/reset.js index 6a96f12a70..3f2202e7c2 100644 --- a/test/core/public/reset.js +++ b/test/core/public/reset.js @@ -72,4 +72,51 @@ describe('axe.reset', function() { assert.equal(axe._audit.reporter, 'v2'); assert.equal(axe._audit.data.rules.bob.knows, 'not-joe'); }); + + describe('when custom locale was provided', function() { + beforeEach(function() { + axe._load({ + data: { + checks: { + banana: { + impact: 'serious', + messages: { + pass: 'yay', + fail: 'boo', + incomplete: 'donno' + } + } + } + }, + checks: [ + { + id: 'banana', + evaluate: function() {} + } + ] + }); + }); + + it('should restore the original locale', function() { + axe.configure({ + locale: { + checks: { + banana: { + pass: 'wonderful', + fail: 'horrible job', + incomplete: 'donno' + } + } + } + }); + + axe.reset(); + + var banana = axe._audit.data.checks.banana; + assert.equal(banana.impact, 'serious'); + assert.equal(banana.messages.pass(), 'yay'); + assert.equal(banana.messages.fail(), 'boo'); + assert.equal(banana.messages.incomplete, 'donno'); + }); + }); }); diff --git a/typings/axe-core/axe-core-tests.ts b/typings/axe-core/axe-core-tests.ts index 2ff6b13694..f30d830a98 100644 --- a/typings/axe-core/axe-core-tests.ts +++ b/typings/axe-core/axe-core-tests.ts @@ -143,3 +143,38 @@ var pluginSrc: axe.AxePlugin = { }; axe.registerPlugin(pluginSrc); axe.cleanup(); + +axe.configure({ + locale: { + checks: { + foo: { + fail: 'failure', + pass: 'success', + incomplete: { + foo: 'nar' + } + } + } + } +}); + +axe.configure({ + locale: { + lang: 'foo', + rules: { + foo: { + description: 'desc', + help: 'help' + } + }, + checks: { + foo: { + pass: 'pass', + fail: 'fail', + incomplete: { + foo: 'bar' + } + } + } + } +});