From 2dbcf8db3fea4923d578ca8198fd7ed8c902a7cf Mon Sep 17 00:00:00 2001 From: Stephen Mathieson Date: Tue, 31 Jul 2018 13:34:27 -0400 Subject: [PATCH 1/3] feat: add runtime localization support This patch adds support for runtime localization. A user may now provide a `locale` object (in the same shape as the locales in `/locale/*.json`) to `axe.configure()`. For example: ```js axe.configure({ locale: { rules: { 'some-rule': { help: 'the help message', description: 'the description' } }, checks: { [...] } } }) ``` These locale strings are run thru `doT`, enabling template support. For example: ```js axe.configure({ locale: { rules: { foo: { help: 'some help', description: 'foo: {{~it.data:value}} {{=value}}{{~}}.' } }, checks: { bar: { pass: 'the pass message', fail: 'something useful: {{~it.data:value}} {{=value}}{{~}}.' } } } }) ``` Upon calling `axe.reset()`, any custom locale will be reset to the "default" axe-core shipped with. --- axe.d.ts | 19 +++ doc/API.md | 5 +- lib/core/base/audit.js | 184 +++++++++++++++++++++++ lib/core/public/configure.js | 7 +- test/core/public/configure.js | 226 +++++++++++++++++++++++++++++ test/core/public/reset.js | 47 ++++++ typings/axe-core/axe-core-tests.ts | 35 +++++ 7 files changed, 521 insertions(+), 2 deletions(-) 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 5e7f730b4a..1b8b5fd70a 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..9c497e7771 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._previousLocale = 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._setPreviousLocale = function() { + if (this._previousLocale) { + 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._previousLocale = locale; +}; + +/** + * Reset the locale to the "default". + */ + +Audit.prototype._resetLocale = function() { + // If no previous locale exists, then we haven't applied one + // and the default is still in-tact. + const previousLocale = this._previousLocale; + if (!previousLocale) { + return; + } + + // Apply the previous locale + this.applyLocale(previousLocale); +}; + +/** + * 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 { + impact: a.impact, + 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 { + 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._setPreviousLocale(); + + 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..866332d3b4 100644 --- a/test/core/public/configure.js +++ b/test/core/public/configure.js @@ -239,4 +239,230 @@ 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' + }); + }); + }); + + 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 previous locale', function() { + assert.isNull(axe._audit._previousLocale); + axe.configure({ + locale: { + lang: 'lol', + checks: { + banana: { + pass: 'yay banana' + } + } + } + }); + assert.ok(axe._audit._previousLocale); + }); + }); }); 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' + } + } + } + } +}); From 8b2c70455bb6157f101888bd3da423e47a14d0b2 Mon Sep 17 00:00:00 2001 From: Stephen Mathieson Date: Wed, 8 Aug 2018 09:55:15 -0400 Subject: [PATCH 2/3] chore: updates from code review --- lib/core/base/audit.js | 24 ++++----- test/core/public/configure.js | 96 +++++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index 9c497e7771..cbb9eebb05 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -44,7 +44,7 @@ function Audit(audit) { // 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._previousLocale = null; + this._defaultLocale = null; } /** @@ -53,8 +53,8 @@ function Audit(audit) { * to the default ("first") configuration. */ -Audit.prototype._setPreviousLocale = function() { - if (this._previousLocale) { +Audit.prototype._setDefaultLocale = function() { + if (this._defaultLocale) { return; } @@ -85,7 +85,7 @@ Audit.prototype._setPreviousLocale = function() { locale.rules[id] = { description, help }; } - this._previousLocale = locale; + this._defaultLocale = locale; }; /** @@ -93,15 +93,14 @@ Audit.prototype._setPreviousLocale = function() { */ Audit.prototype._resetLocale = function() { - // If no previous locale exists, then we haven't applied one - // and the default is still in-tact. - const previousLocale = this._previousLocale; - if (!previousLocale) { + // If the default locale has not already been set, we can exit early. + const defaultLocale = this._defaultLocale; + if (!defaultLocale) { return; } - // Apply the previous locale - this.applyLocale(previousLocale); + // Apply the default locale + this.applyLocale(defaultLocale); }; /** @@ -140,7 +139,7 @@ const mergeCheckLocale = (a, b) => { fail = axe.imports.doT.compile(fail); } return { - impact: a.impact, + ...a, messages: { pass: pass || a.messages.pass, fail: fail || a.messages.fail, @@ -171,6 +170,7 @@ const mergeRuleLocale = (a, b) => { description = axe.imports.doT.compile(description); } return { + ...a, help: help || a.help, description: description || a.description }; @@ -214,7 +214,7 @@ Audit.prototype._applyRuleLocale = function(rules) { */ Audit.prototype.applyLocale = function(locale) { - this._setPreviousLocale(); + this._setDefaultLocale(); if (locale.checks) { this._applyCheckLocale(locale.checks); diff --git a/test/core/public/configure.js b/test/core/public/configure.js index 866332d3b4..8b684d4daa 100644 --- a/test/core/public/configure.js +++ b/test/core/public/configure.js @@ -394,6 +394,7 @@ describe('axe.configure', function() { ] }); }); + it('should support strings', function() { axe.configure({ locale: { @@ -430,6 +431,44 @@ describe('axe.configure', function() { }); }); + // 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({ @@ -450,8 +489,8 @@ describe('axe.configure', function() { }, /unknown check: "nope"/); }); - it('should set previous locale', function() { - assert.isNull(axe._audit._previousLocale); + it('should set default locale', function() { + assert.isNull(axe._audit._defaultLocale); axe.configure({ locale: { lang: 'lol', @@ -462,7 +501,58 @@ describe('axe.configure', function() { } } }); - assert.ok(axe._audit._previousLocale); + 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.'); + }); + }); }); }); }); From a60a9ef5329bad2f816cc08621f8060db241eb48 Mon Sep 17 00:00:00 2001 From: Stephen Mathieson Date: Wed, 8 Aug 2018 12:00:06 -0400 Subject: [PATCH 3/3] doc(README): add runtime locale info --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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.