Lately, I have been actively contributing to a11y.css project. If you don’t know it yet and happen to be interested in accessibility, I highly recommand you give it a glance. It is a massive work from French developer and accessibility activist Gaël Poupard.
As far as I am concerned, I am no accessibility expert, so I always find this kind of initiatives very helpful. To briefly introduce a11y.css, it is a stylesheet that you can include in any web page to highlight possible mistakes, errors and improvements. Each notification comes with a message (displayed with pseudo-elements) explaining what’s going on and what should be done. Cool stuff, really.
I thought it was too bad to keep it exclusively in French so I opened an issue to suggest a Sass solution (project was already running on Sass anyway) to provide messages in different languages. I am very happy with what I have come up hence this article to explain how I did it.
Introducing the API
The goal was not to switch the whole thing to English. I think Gaël wanted to keep French and in the meantime provide an English version. So the idea was to find a way to generate a stylesheet per language. Feel like adding Spanish? Go for it, should be a breeze.
My idea was to have a .scss
file per language, following a pattern like a11y-<language>.scss
for convenience that gets compiled into a a11y-<language>.css
file. This file shouldn’t contain much. Actually only:
- defining
@charset
(obviously toUTF-8
); - importing utilities (translation map, mixins, configuration…);
- defining the language to use (as of today
fr
oren
); - importing CSS styles.
For instance, a11y-en.scss
would look like:
@charset "UTF-8";
@import 'utils/all';
@include set-locale('en');
@import 'a11y/a11y';
Looking pretty neat, right?
Setting the language
You’ve seen from the previous code snippet that we have a set-locale
mixin accepting a language (shortcut) as a parameter. Let’s see how it works:
/// Defines the language used by `a11y.css`. For now, only `fr` and `en` allowed.
/// @group languages
/// @param {String} $language
/// @output Nothing
/// @example scss - Defines the language to `fr`.
/// @include set-locale('fr');
@mixin set-locale($language) {
$supported-languages: 'fr', 'en';
$language: to-lower-case($language);
@if not index($supported-languages, $language) {
@error "Language `#{$language}` is not supported. Pull request welcome!";
}
$language: $language !global;
}
There is very little done here. First, it makes sure the given language is supported. For now, only fr
and en
are. If it is not supported, it throws an error. Else, it creates a global variable called $language
containing the language (fr
or en
). Easy, let’s move on.
Gathering all messages within a map
The point of this system is to gather all messages within a big Sass map. Thus, we don’t have dozens of strings scattered across stylesheets. Every single message, no matter the language, lives inside the $messages
map. Then, we’ll have an accessor (a getter function) to retrieve a message from this map depending on the global language.
Gaël has divided messages in different themes: errors
, advices
or warnings
. This is the first level of our map.
$messages: (
'errors': (),
'advices': (),
'warnings': ()
);
Then each theme gets mapped to a sub-map (second level) containing keys for different situations. For instance, the error
telling that there a missing src
attribute on images:
[src] attribute missing or empty. Oh, well…
… is arbitrary named no-src
.
$messages: (
'errors': ('no-src': ()),
'advices': (),
'warnings': ()
);
And finally, this key is mapped to another sub-map (third level) where each key is the language and each value the translation:
$messages: (
'errors': ('no-src': ('fr': 'Attribut [src] manquant ou vide. Bon.', 'en':
'[src] attribute missing or empty. Oh, well…')),
'advices': (),
'warnings': ()
);
However fetching fr
key from no-src
key from errors
key from $messages
map would look like:
$message: map-get(map-get(map-get($messages, 'errors'), 'no-src'), 'fr')));
This is both ugly and a pain in the ass to write. With a map-deep-get
function, we could shorten this to:
$message: map-deep-get($messages, 'errors', 'no-src', 'fr');
Much better, isn’t it? Although having to type the language over and over is not very convenient. And we could also make sure errors
is a valid theme (which is the case) and no-src
is a valid key from theme errors
(which is also the case). To do all this, we need a little wrapper function. Let’s call it message
, in all its simplicity:
/// Retrieve message from series of keys
/// @access private
/// @param {String} $theme - Either `advice`, `error` or `warning`
/// @param {String} $key - Key to find message for
/// @requires $messages
/// @return {String} Message
@function message($theme, $key) {
$locale: if(global-variable-exists('language'), $language, 'en');
@if not index(map-keys($messages), $theme) {
@error "Theme `#{$theme}` does not exist.";
}
@if not index(map-keys(map-get($messages, $theme)), $key) {
@error "No key `#{$key}` found for theme `#{$theme}`.";
}
@return map-deep-get($messages, $theme, $key, $locale);
}
The message
function first deals with the language. If a global variable called language
exists — which is the case if set-locale
has been called — it uses it, else it falls back to en
. Then, it makes sure arguments are valid. Finally, it returns the result of map-deep-get
as we’ve seen above.
So we could use it like this:
img:not([src])::after {
content: message('errors', 'no-src');
}
Pretty cool! Although having to type content
everywhere could be avoided. Plus, Gaël uses !important
in order to make sure the messages are correctly being displayed. Let’s have a message
mixin wrapping around message
function!
/// Get a message from the translation map based on the defined language.
/// The message contains the icon associated to the message type.
/// @group languages
/// @param {String} $theme - Theme name
/// @param {String} $key - Key name
/// @require {function} message
/// @output `content`, with `!important`
/// @example scss - Get message for `no-src` from `errors` category when language is set to `en`
/// .selector {
/// @include message('errors', 'no-src');
/// }
/// @example css - Resulting CSS
/// .selector {
/// content: '[src] attribute missing or empty. Oh, well…';
/// }
@mixin message($theme, $key) {
content: message($theme, $key) !important;
}
Same arguments. No logic. Nothing but the content
property with !important
. Thus we would use it like this:
img:not([src])::after {
@include message('errors', 'no-src');
}
We’re done. It’s over!
Final thoughts
Cases where we need a translation system in Sass are close to zero, but for a11y.css this work proves to be useful after all. Adding a new language, for instance German, is as easy as adding a de
key to all messages in the $messages
map, and adding de
to $supported-languages
within set-locale
mixin.
That’s it! Anyway, have a look at a11y.css, contribute to this awesome project and share the love!