Stele - How Stele Works
How Stele Works
Stele is a compile-time translation library that can also operate at runtime. Stele works by taking the code you write and transforming it in to an ICU message. Then Stele can take ICU messages and convert that back in to run time code. This, however, gives Stele some limitations. I feel that understanding how Stele works can make it easier to understand why you have to make some small tweaks to your code.
Why it has to be this way?
Real humans are the ones most likely to be doing our translations. They need to be able to see the entirety of a message to correctly translate it. ICU messages are just a fancy way of doing string interpolation that human translators understand. ICU messages give us an API to carefully construct accurate messages to send to translators.
Let's break this down by parts:
Creating an ICU message
Stele looks at your code in an abstract way. It does not know what your code does, or why. All Stele knows is what it can see when webpack or babel compiles your application. Stele does this by using the Abstract Syntax Tree that babel provides. The babel team has a great guide on ASTs here.
I am writing this guide without the requirement of knowing more about ASTs however. Also, here we are only going to cover message extraction, not replacement.
Steps
- Turn Code in to an AST
- Filter down to only translatable JSX calls.
- Go through children and turn them in to corresponding ICU string.
- Extract out context from JSX call
A simple starting point
<p intl={locale}>We are having a sale all day today!</p><p>We don't need to translate this</p>
Once you mark this JSXElement
as something translatable Stele gets to work.
The first thing it does is convert your code to babel's AST, which very
abstractly looks something like this:
{openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), Identifier('locale'))]),children: [StringLiteral('We are having a sale all day today!')]}, {openingTag: JSXOpeningTag('p'),props: ObjectExpression(),children: [StringLiteral("We don't need to translate this")]}
Now our abstract syntax tree gives us something we can easily iterate and look
through. The first thing Stele does is try to find all the JSX tags in your
file. Once it does that it filters them down to only those who have your
.babelrc
configuration for what is to be internationalized. Say that is all
JSXElements with a prop called intl
that reduces our list to:
{openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), null)]),children: [StringLiteral('We are having a sale all day today!')]}
Steps (1) and (2) are now complete!
For Step (3) Stele basically just has a fancy logic statement that looks like this in pseudocode:
const icuMessage = jsxElement.children.reduce((message, child) => {// if the child is a string, just add it to the messageif (isStringLiteral(child)) {message = message + child.value}// other if statementsreturn message}, '')
So in our simple case above we create the message
We are having a sale all day today!
.
Handling arguments
Let's say we augment our original statement a little bit to have a sale on something specific:
<p intl={locale}>We are having a sale all day today on {category}!</p>
Getting through step (2) above leaves us with something like this:
{openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), Identifier('locale'))]),children: [StringLiteral('We are having a sale all day today '), Identifier('category'), StringLiteral('!')]}
Now our original reduce method needs to be able to handle Identifiers
and not
just StringLiterals
.
const icuMessage = jsxElement.children.reduce((message, child) => {// if the child is a string, just add it to the messageif (isStringLiteral(child)) {message = message + child.value}if (isIdentifier(child)) {message = message + `{${child.name}}`}// other if statementsreturn message}, '')
Now what we do is for an argument we create {category}
and append it to the
string. So in the loop it looks like this:
- ''
- StringLiteral('We are having a sale all day today ') -> 'We are having a sale all day today '
- Identifier('category') -> 'We are having a sale all day today {category}'
- StringLiteral('!') -> 'We are having a sale all day today {category}!'
Can Stele infer the value of a variable?
Let's say I had a really long string and I wanted to substitute part of it out I could do something like this:
const duration = `We are having a sale all day today on ${category}!`<p intl={locale}>{duration}</p>
In this case does Stele know the full string is
We are having a sale all day today on {category}!
?
The answer is no, stele would produce the message:
{duration}
To figure out why let's look at what Stele sees:
{openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), Identifier('locale'))]),children: [Identifier('duration')]}
Because Stele first finds JSX tags, it does not get the entire scope of the
application. Therefore, Stele has no idea where duration
is defined or what
possible values it can have. The only information Stele has is that there is an
identifier named duration
in it's children. Babel does not send along
information about where that value is defined, or what value it could possibly
have.
Stele Components
Let's say we wanted to add a plural to our message. Let's say we wanted to have a sale on multiple categories:
<p intl={locale}>We are having a sale all day today on{' '}<Plural value={numCategories} one="something special" other="# things" />!</p>
This gives Stele the following:
const ast = {openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), Identifier('locale')),]),children: [StringLiteral('We are having a sale all day today '),JSXElement(Identifier('Plural'),ObjectExpression(MemberExpression(StringLiteral('one'), StringLiteral('something special'))MemberExpression(StringLiteral('other'), StringLiteral('# things'))))StringLiteral('!'),],}
Now we need to do a little updating of the reducer function
const icuMessage = jsxElement.children.reduce((message, child) => {/* stuff from above... */if (isJSXElement(child)) {// look inside child.openingTag if that matches a stele element continueif (isSteleElement(child)) {if (child.openingTag.name === 'Plural') {return handlePlural(child)}}}// other if statementsreturn message}, '')
I won't put the precise logic for how the plural element specifically is handled. But basically we just go through the props construct an ICU plural message from that. Which would look something like this:
We are having a sale all day today on {numCategories,plural,one {something special}other {# things}}!
And Step 3 is done!
Handling nesting
Let's say we wanted our plural text to link to our sale page.
<p intl={locale}>We are having a sale all day today on{' '}<Link to="/on-sale"><Pluralvalue={numCategories}one="something special"other="# things"/>!</Link></p>
Fortunately because we use Stele we can use the Link
from react-router or your
design system easily!
What does Stele see with this?
const ast = {openingTag: JSXOpeningTag('p'),props: ObjectExpression([ObjectMemberExpression(StringLiteral('intl'), Identifier('locale')),]),children: [StringLiteral('We are having a sale all day today '),JSXElement(Identifier('Link'),ObjectExpression(MemberExpression(StringLiteral('to'), StringLiteral('/how-to')))JSXElement(Identifier('Plural'),ObjectExpression(MemberExpression(StringLiteral('one'), StringLiteral('something special'))MemberExpression(StringLiteral('other'), StringLiteral('# things')))))StringLiteral('!'),],}
Now what we want to do, is keep the hierarchy intact. We do it with just a little bit of magic characters.
let messageCounter = 0const extractMessage = (message, child) => {/* stuff from above... */if (isJSXElement(child)) {/* the same stuff above */if (isSteleElement(child)) {} else {messageCounter++message = `<${messageCounter}>${jsxElement.children.reduce(extractMessage,'',)}</${messageCounter}>`}}// other if statementsreturn message}const icuMessage = jsxElement.children.reduce(extractMessage, '')
RECURSION! We basically, are repeating the same logic in the children as if it
is a new message. We also wrap it with pseudo-HTML tags: <1></1>
. In the end
we get a message that looks like:
We are having a sale all day today on <1>{numCategories,plural,one {something special}other {# things}}!</1>