Skip to content

Commit

Permalink
feat: add runtime localization support (#1036)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
stephenmathieson authored Aug 8, 2018
1 parent 5d6c1fa commit 7d4b70f
Show file tree
Hide file tree
Showing 8 changed files with 637 additions and 2 deletions.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ To create a new translation for aXe, start by running `grunt translate --lang=<l

To update existing translation file, re-run `grunt translate --lang=<langcode>`. 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.
Expand Down
19 changes: 19 additions & 0 deletions axe.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -79,6 +97,7 @@ declare namespace axe {
reporter?: ReporterVersion;
checks?: Check[];
rules?: Rule[];
locale?: Locale;
}
interface Check {
id: string;
Expand Down
5 changes: 4 additions & 1 deletion doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ axe.configure({
},
reporter: "option",
checks: [Object],
rules: [Object]});
rules: [Object],
locale: Object
});
```

#### Parameters
Expand Down Expand Up @@ -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

Expand Down
184 changes: 184 additions & 0 deletions lib/core/base/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -367,4 +550,5 @@ Audit.prototype._constructHelpUrls = function(previous = null) {
Audit.prototype.resetRulesAndChecks = function() {
'use strict';
this._init();
this._resetLocale();
};
7 changes: 6 additions & 1 deletion lib/core/public/configure.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* global reporters */
function configureChecksRulesAndBranding(spec) {
/*eslint max-statements: ["error",20]*/
/*eslint max-statements: ["error",21]*/
'use strict';
var audit;

Expand Down Expand Up @@ -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;
Loading

0 comments on commit 7d4b70f

Please sign in to comment.