-
Notifications
You must be signed in to change notification settings - Fork 38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Restructure localized strings (RFC) #1048
Conversation
Actions from discussion with team today:
|
I have taken the actions decided at yesterday's meeting. It is unfortunately not possible to use I will proceed next to migrate the entire codebase to use the new pattern. |
Some updates:
News:
|
action={ | ||
<FormattedMessage id={`zui.accessList.roles.${item.role}`} /> | ||
} | ||
action={<Msg id={messageIds.global.roles[item.role]} />} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example of the new global
map that is always included in the map returned by makeMessages()
|
||
const newView = await apiClient.post<ZetkinView, ZetkinViewPostBody>( | ||
`/api/orgs/${orgId}/people/views`, | ||
{ | ||
folder_id: folderId || undefined, | ||
title: messages['misc.views.newViewFields.title'], | ||
title: messages.newViewFields.title(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New getServerMessages()
in action here.
I actually managed to solve the issue of overloading |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You´re making the line skyrocketing! Wohoooo! 🚀
Description
This PR is a proof-of-concept and request-for-comments proposal for a new way to structure and handle internationalization.
Some terminology
These terms are used in the description of this PR (and in the code).
messages()
,<Msg/>
anduseMessages()
is the process of internationalizingtemplate ${strings}
, using'concatenated' + ' ' + 'strings'
or any other mechanism{orgCount, plural, =0 {No organizations} =1 {One organization} other {# organizations}}
)Intl
API is a browser and Node API used for localization. It uses the ICU MessageFormat standard, and is used behind the scenes by thereact-intl
library we used for internationalizationBenefits, in short
Here is a short list of benefits (compared to the old system). See details in the sections below.
'long.id.strings'
values={{ foo: 'bar' }}
ugly.effing.id.string
rendered to the userDefining messages (and default strings)
With the old format there was no reliable source of truth for what messages (should) exist. You could refer to a message in code by the string
'some.nice.id'
and the program would expect it to exist. But you could also define a localized string in a YAML file in a way that translates to the ID'some.other.id'
. In this case, do you have two different messages (one missing, one unused) or a single one which was misspelled?With this PR, the source of truth for which messages exist is the
messages.ts
file in each feature. Messages are defined using themessages()
function, along with a default (English) text. Here's an example from the migratedsearch
feature:The messages can then be referenced, not by their string ID, but by their actual typescript identifier/name, i.e.
messages.results.campaign
. This means that they can never be misspelled, and also that we get some extra type safety.Type-safe interpolation values
The ICU MessageFormat has support for variables, that are (in their simplest form) referenced in localized messages using a
{placeholder}
. A property with the correct name (in this caseplaceholder
) must be passed to the relevantIntl
method (generallyIntl.formatMessage()
) or an error is thrown.In the old solution, accidentally forgetting to pass values, or passing values by the wrong names was a very simple mistake to make. Especially when refactoring.
With this PR, the shape of the values passed to a message is made type-safe, by defining the shape at the time of defining the message, using
im()
(for "Interpolated Message") instead of the simplerm()
function. Below is an example from the migratedtags
feature:Trying to use the
dialog.groupCreatePrompt
message anywhere in the code will only work if thegroupName
property is supplied with astring
value. This is enforced by the<Msg/>
component and theuseMessages()
hook.The
<Msg/>
componentThe new
<Msg/>
component is inspired by the<FormattedMessage/>
component fromreact-intl
(and how we've traditionally sometimes abbreviate it as<Msg/>
).The
<Msg/>
component can be used in two ways, for plain messages and interpolated messages respectively:The
<Msg/>
component will render a fragment with the formatted string in it. No extra DOM elements will be created.The
useMessages()
hookA very common scenario is wanting to use a message (for internationalization) but not wanting to render it as a React element, e.g. to use it as the value of a
string
property or send it to the API. For those scenarios we've traditionally useduseIntl()
, which returns a reference toIntl
, and then doneintl.formatMessage({ id: 'some.id' })
.This has the obvious issues of using string IDs, but has the added annoyance of requiring an object as it's first parameter (not just an ID string) and another object for it's values, often resulting in expressions that look like this to simply format a string with some value:
The new
useMessages()
hook is an alternative to this, which takes a map of messages (frommessages()
) and creates a new map where every message is a function that can be called to get the localized string. It employs the same measures of type-safety as<Msg/>
, and can be used like this:Let's compare that old
intl.formatMessage()
with the new expression:Localizing (defining translations)
Messages are still localized in YAML files. I have not at this point made any changes to where they are defined as that's a separate (but related) matter from the type-safe internationalization framework.
A message defined with
messages('feat.tags', { title: 'Tags' })
could be translated to Swedish in a YAML file atsrc/locale/feat/tags/sv.yml
astitle: Etiketter
, or the file could be atsrc/features/tags/locale/sv.yml
, or according to some other pattern.That's something we need to decide next.
Loading translations
In the current codebase, localized messages are read from the YAML files by the server, and then served alongside other data as part of the page load. This means that every page needs to define what messages are needed, which is done using the
localeScope
option forscaffold()
.This PR doesn't really change this, except in one way. Default (English) translations are now included as part of the message definitions (
m('This is the default')
) which means that if there is something preventing the localized (e.g. Swedish) messages from loading, usually programmer mistake, there will absolutely always be an English fallback, because if the ID was referenced in the code, it will have been compiled into the javascript.This means that the "Missing message" errors will no longer happen, and the
message.id.strings
that would previously render in the UI will instead always fall back to English.Because all messages pass through a single point at runtime (
messages()
) it's possible to aggregate while rendering a page what subsets of messages are likely needed. This could (I'm not sure) allow us to flag them as needed and load them, without having to specifylocaleScope
.The type safety and single source of truth also opens up for some creative ways of solving this and other issues using tooling.
Tooling potential
Now that messages are code, and there is a single source of truth for what messages should exist, that opens up for some interesting tooling potential. Here are some ideas.
NOTE: I have made some proof of concept experiments (not in this PR) using
ts-morph
to verify that the following ideas are viable.ts-morph
is a TypeScript AST API, which basically lets us inspect typescript code programmatically, allowing us to build tools like these.ts-morph
equivalent of right-clicking a message in VSCode and selecting "Find references")localeScope
for that page (and hence will not be loaded). This could be made obsolete by an even smarter runtime loader, which may or may not be possible.en.yml
files from the messages, which we could use to generate the files that are needed for our translate.zetkin.org interface to work.Re: Dates, numbers etc
Nothing changes for numbers, dates, etc. This PR only concerns "messages", i.e. predefined strings – how to define them, reference them etc. That's where we've been having problems.
For dates, numbers etc, the
react-intl
components (e.g.<FormattedDate/>
) work fine. In some cases we've wrapped them (e.g.<ZUIRelativeTime/>
). In other cases where dates and numbers are concerned, they are actually inside messages and can be localized using MessageFormat (e.g.{orgCount, number}
).Screenshots
None
Changes
core/i18n
messages()
,m()
andim()
<Msg/>
component inspired by<FormattedMessage/>
but type-safeuseMessages()
component, designed as a type-safe replacement for most of our use-cases foruseIntl()
search
featuretags
featureNotes to reviewer
I would like to discuss the following questions:
src/features/tags/locale/sv.yml
), or still globally (e.g.src/locale/feat/tags/sv.yml
)?useMessages()
?const msg = useMessages(messages)
but I don't love it, becausemsg
implies a single message when it's actually a map of all messages.messages.ts
asmessageIds
instead, and then doconst messages = useMessages(messageIds)
?Related issues
Related to #998