Software localization
An I18n Walkthrough for Falcon Web Apps in Python
Falcon is a high-performance web framework for building a REST API and micro-services in Python. Fast, reliable, and flexible, it can compile itself with Cython, providing an additional boost to the total number of requests per second.
Falcon can also be used for building web applications. Nevertheless, Falcon's official documentation does not include any guidelines on internationalization for those who want to build a multilingual web application That is exactly the gap this tutorial seeks to close.
Setup
Get the reference code for the demo app from our GitHub repository to get the ball rolling.
Folder structure
The folder structure for this tutorial is as follows:
├── locales │ ├── en │ └── de ├── templates | └── index.html ├── main.py ├── babel.cfg └── requirements.txt
Installation via a package name
Activate the virtual environment and run the following command to install all the dependencies:
- falcon
- babel
- jinja2
- uvicorn
pip install falcon pip install babel pip install jinja2 pip install uvicorn
Installation via a requirement file
Alternatively, you can use the following requirement file:
asgiref==3.4.1 Babel==2.9.1 click==8.0.3 colorama==0.4.4 falcon==3.0.1 h11==0.12.0 importlib-metadata==4.8.1 Jinja2==3.0.2 MarkupSafe==2.0.1 pytz==2021.3 typing-extensions==3.10.0.2 uvicorn==0.15.0 zipp==3.6.0
The command for installing via a requirement file is as follows:
pip install -r requirements.txt
HTML files
This tutorial is based on the following webpage template provided by W3.CSS.
Create a new folder called templates and a new file called index.html inside it. To keep it simple, we will modify the code to a lite version that contains only the "About Us" section. Let us start with the following boilerplate:
<!DOCTYPE html> <html> <title>{{ _("Gourmet au Catering") }}</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css"> <style> body {font-family: "Times New Roman", Georgia, Serif;} h1, h2, h3, h4, h5, h6 { font-family: "Playfair Display"; letter-spacing: 5px; } </style> <body> </body> </html>
Next, define the navigation bar at the top of the webpage inside the <body>
tag:
<div class="w3-top"> <div class="w3-bar w3-white w3-padding w3-card" style="letter-spacing:4px;"> <a href="#home" class="w3-bar-item w3-button">{{ _("Gourmet au Catering") }}</a> <!-- Right-sided navbar links. Hide them on small screens --> <div class="w3-right w3-hide-small"> <a href="#about" class="w3-bar-item w3-button">{{ _("About Us") }}</a> <a href="#menu" class="w3-bar-item w3-button">{{ _("Menu") }}</a> <a href="#contact" class="w3-bar-item w3-button">{{ _("Contact") }}</a> </div> </div> </div>
Finalize the HTML file with the following code for the "About Us" section below the above code:
<div class="w3-content" style="max-width:1100px"> <!-- About Section --> <div class="w3-row w3-padding-64" id="about"> <div class="w3-col m6 w3-padding-large w3-hide-small"> <img src="https://www.w3schools.com/w3images/tablesetting2.jpg" class="w3-round w3-image w3-opacity-min" alt="Table Setting" width="600" height="750"> </div> <div class="w3-col m6 w3-padding-large"> <h1 class="w3-center">{{ _("About Us") }}</h1><br> <h5 class="w3-center">{{ _("Tradition since 1889") }}</h5> <p class="w3-large">{{ _("Description") }}</p> <h4><strong>{{ _("Upcoming Event") }}</strong></h4> <ul> <li><strong>{{ _("Date") }}:</strong> {{ event_date | date_filter(locale) }}</li> <li><strong>{{ _("Time") }}:</strong> {{ event_time | time_filter(locale) }}</li> </ul> <p><i>{{ event_attendee | num_filter(locale) }} {{ ngettext('attendee', 'attendees', event_attendee) }}</i></p> </div> </div> </div>
The final user interface (UI) should look like this:
Message catalog
String translation
You should notice that the underlying HTML content contains a number of variables that are enclosed by double curly brackets and wrapped around a _
function:
<p>{{ _("About Us") }}</p>
These are part of the Jinja2 syntax for string translation with gettext. _
is the alias form of gettext. This means that you can call via the following code as well:
<p>{{ gettext("About Us") }}</p>
🗒 Note » If you are interested in localization with gettext, we suggest the following tutorials:
- How to Translate Python Applications with The GNU gettext Module
- Learning gettext tools for Internationalization (i18n)
Internally, Jinja2 will overwrite the output based on the context that you have passed when rendering the template. In this case, we are using message catalogs as the translations files.
Fortunately, Babel comes with the command line tool for working with message catalogs:
- extract—extract messages from source files and generate a POT file
- init—create new message catalogs from a POT file
- update—update existing message catalogs in a POT file
- compile—compile message catalogs to MO files
Extract
Before that, let's create a configuration file called babel.cfg, which defines the extraction method. This is crucial if you intend to extract from HTML instead of Python files.
[python: **.py] encoding = utf-8 [jinja2: **.html] encoding = utf-8 extensions=jinja2.ext.autoescape,jinja2.ext.with_
Now, run the extract command to create a POT file. It will get all the strings that are passed to _
or gettext-related functions. When calling extract, you need to pass in the following arguments:
- path to the configuration file (via
-F
arg) - output path (via
-o
arg) - directory path or files path
Create a new folder called locales and run the command below:
pybabel extract -F babel.cfg -o locales/messages.pot templates/
You should see the following output:
extracting messages from templates\index.html (encoding="utf-8", extensions="jinja2.ext.autoescape,jinja2.ext.with_") writing PO template file to locales/messages.pot
It will generate messages.pot in the locales folder with the following content:
... #: templates/index.html:19 msgid "Gourmet au Catering" msgstr "" #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "" #: templates/index.html:23 msgid "Menu" msgstr ""
The messages.pot file represents the base translations file for all the locales. Please note that msgid is the unique identifier for each message to be translated. On the other hand, msgstr represents the translation text.
Leave the translation text empty as you will copy this file into individual folders for each of the supported locales later on.
Init
The next step is to execute the init command. It will generate a single folder with a copy of the translation file inside it on each run. It accepts the following arguments:
- locale (-l)
- input file (-i)
- output directory (-d)
Let us say your application supports the following locales:
- en (English)
- de (German)
Run the command as follows for each of the locales that you intend to support:
pybabel init -l en -i locales/messages.pot -d locales pybabel init -l de -i locales/messages.pot -d locales
Each command will generate a folder with a LC_MESSAGES subfolder inside it. You can find a file called messages.po, which represents the human-editable translation file for the locale. You should fill in the corresponding translation text here.
... #: templates/index.html:19 msgid "Gourmet au Catering" msgstr "" #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "Ăśber uns" #: templates/index.html:23 msgid "Menu" msgstr "MenĂĽ"
Also, if you leave the translation string empty, it will use the text from msgid instead as highlighted above.
You can find the translation files for English at the following link:
#: templates/index.html:19 msgid "Gourmet au Catering" msgstr "" #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "" #: templates/index.html:23 msgid "Menu" msgstr "" #: templates/index.html:24 msgid "Contact" msgstr "" #: templates/index.html:40 msgid "Tradition since 1889" msgstr "" #: templates/index.html:41 msgid "Description" msgstr "" "We offer full-service catering for any event, large or small. We " "understand your needs and we will cater the food to satisfy the biggerst " "criteria of them all, both look and taste. Do not hesitate to contact us." #: templates/index.html:42 msgid "Upcoming Event" msgstr "" #: templates/index.html:44 msgid "Date" msgstr "" #: templates/index.html:45 msgid "Time" msgstr "" #: templates/index.html:47 #, fuzzy, python-format msgid "attendee" msgid_plural "attendees" msgstr[0] "" msgstr[1] ""
The German translation file is as follows:
#: templates/index.html:19 msgid "Gourmet au Catering" msgstr "" #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "Über uns" #: templates/index.html:23 msgid "Menu" msgstr "Menü" #: templates/index.html:24 msgid "Contact" msgstr "Kontakt" #: templates/index.html:40 msgid "Tradition since 1889" msgstr "Tradition seit 1889" #: templates/index.html:41 msgid "Description" msgstr "" "Wir bieten Full-Service-Catering für jede Veranstaltung, ob groß oder " "klein. Wir verstehen Ihre Bedürfnisse und werden die Speisen so " "zubereiten, dass sie die höchsten Kriterien erfüllen, sowohl optisch als " "auch geschmacklich. Zögern Sie nicht, uns zu kontaktieren." #: templates/index.html:42 msgid "Upcoming Event" msgstr "Kommende Veranstaltung" #: templates/index.html:44 msgid "Date" msgstr "Datum" #: templates/index.html:45 msgid "Time" msgstr "Zeit" #: templates/index.html:47 #, fuzzy, python-format msgid "attendee" msgid_plural "attendees" msgstr[0] "Teilnehmer" msgstr[1] "Teilnehmer"
Update
This command is essential when adding new translations to your web application. You can simply use it to update the underlying content after extraction:
pybabel extract -F babel.cfg -o locales/messages.pot templates/ pybabel update -i locales/messages.pot -d locales
Any new translations will be added to all the messages.po file while preserving the existing translations.
Compile
Once you have finished adding in the translations, you have to compile the PO files into MO files. The latter represents the machine-readable format file for gettext. Run the following command on your terminal:
pybabel compile -d locales
You should see the following output:
compiling catalog locales\de\LC_MESSAGES\messages.po to locales\de\LC_MESSAGES\messages.mo compiling catalog locales\en\LC_MESSAGES\messages.po to locales\en\LC_MESSAGES\messages.mo
It will generate a messages.mo file for each of the messages.po file that you have in the locales folder.
Basic messages
Let's recap the basic concepts for string translation. To mark a string for translation, you should wrap the translation string inside the _
function enclosed by two curly brackets:
<a href="#about">{{ _("About Us") }}</a>
_
function will load the translation by its id. Consider the following language files:... #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "" ...
... #: templates/index.html:22 templates/index.html:39 msgid "About Us" msgstr "Ăśber uns" ...
Given these, when our template renders, we will get "About Us" as the output for the English locale and "Ăśber uns" for the German one.
String interpolation
You can easily interpolate content via the %
placeholder and the built-in format
filter. Let us modify the Hello, Phrase!
example to the following code:
<p>{{ _("Hello, %(user)s!")|format(user=name) }}</p>
After extraction, in the German (de) messages.po file, simply fill in the corresponding translation text while retaining the placeholder as follows:
#: templates/index.html:19 msgid "Hello, %(user)s!" msgstr "Hallo, %(user)s!"
Given that the value of the name
variable is Phrase
, you should get the following output:
<p>Hallo, Phrase!</p>
For integer interpolation, you can use %(variable)d
instead. Likewise, you should use %(variable)f
if you intend to interpolate floating point. By default, it will parse to 6 decimal points. You can easily modify it to 2 decimal points via %(variable).2f
. The syntax and rules are based on the printf-style string formatting in Python.
Pluralization
For pluralization, you should use the ngettext
function instead of the usual gettext
function or its alias _
. ngettext
takes in three important parameters:
- msgid—text representing the msgid
- mgsid_plural—text representing the msgid_plural
- n—plural determiner
For example, you can use it the following way:
<p>{{ event_attendee }} {{ ngettext('attendee', 'attendees', event_attendee) }}</p>
In this case, Babel will generate the following code inside messages.po:
#: templates/index.html:47 #, fuzzy, python-format msgid "attendee" msgid_plural "attendees" msgstr[0] "" msgstr[1] ""
When using ngettext, you will get a list of msgstr as shown above.
The number of msgstr is based on the number of plural forms specified for the locale. Babel generates it automatically for you when running the init command. Simple pluralization, such as the example above, contains two plural forms with msgstr[0] indicating the text for the first plural form and msgstr[1] the second plural form.
You can easily change the plural formula inside the message.po file if you want to support more than two plural forms. Have a look at the following example:
# 2 plural forms "Plural-Forms: nplurals=2; plural=(n != 1)\n" # 3 plural forms "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" # 6 plural forms "Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : " "n%100>=3 && n%100<=10 ? 3 : n%100>=0 && n%100<=2 ? 4 : 5)\n"
Let us suppose that you work in Russian, which has 3 plural forms:
one
: 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, ...a few
: 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …many
: 0, 5~19, 100, 1000, 10000, 100000, 1000000, …
Assuming that you have an integer variable called num_day
, you can add the following code to your HTML file:
<p>{{ ngettext('%(num)d day', '%(num)d days', num_day)|format(num=num_day) }}</p>
Please note that num
is the name of the placeholder while num_day
represents the actual variable that contains an integer. Fill in the corresponding translation in messages.po:
msgid "%(num)d day" msgid_plural "%(num)d days" msgstr[0] "%(num)d день" msgstr[1] "%(num)d дня" msgstr[2] "%(num)d дней"
Depending on the value of the num_day
variable, the output should be as follows:
#num_day
= 1 1 день #num_day
= 2 2 дня #num_day
= 5 5 дней
Custom filters
Jinja2 has its own support for custom filters—regular Python functions that modify the output text. You can use them to post-process the output text. For example, if you have a Python function that converts a string into uppercase called uppercase_filter
, you can use the custom filters in your HTML file as follows:
<p>{{ "Hello, world!" | uppercase_filter }}</p>
It will call the uppercase_filter
function and pass in the value of variable_name
as the first input parameter:
uppercase_filter('Hello, world!')
For functions that accept multiple input parameters, you can pass it normally as follows:
<p>{{ variable_name | markup_filter('xml') }}</p>
Internally, Jinja2 will call it as follows (the first parameter is always tied to the value of the variable_name
):
markup_filter('Hello, world!', 'xml')
Date formatting
If you have a Date object called event_date
and a filter called date_filter
that parses the text based on an input parameter called locale
, you should use the following code in your HTML file:
<p>{{ event_date | date_filter }}</p>
In your server code, you need to define the corresponding functions as follows:
import jinja2 from babel.dates import format_date env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates')) @jinja2.contextfilter def date_filter(context, input, locale=default_fallback): if context: context_locale = context.get('locale', default_fallback) else: context_locale = locale return format_date(input, format='full', locale=context_locale) env.filters['date_filter'] = date_filter ...
It contains a jinja2.contextfilter decorator at the top of the function. The decorator will pass the context
object as the first parameter, followed by the input
as the second parameter, which can help you easily identify the active locale via context['locale']
.
format_date
is part of the built-in function in Babel. It accepts a Date object and the corresponding locale:
format_date(datetime.date(2021, 12, 4), locale='en') # 'December 4, 2021' format_date(datetime.date(2021, 12, 4), locale='de') # 4. Dezember 2021
The function also accepts an optional format parameter with the following options:
- short
- medium (the default value)
- long
- full
You can call it as follows:
format_date(datetime.date(2021, 12, 4), format='full' locale='en') # 'Saturday, December 4, 2021'
Next, register each custom filter manually one by one via Jinja2's Environment instance:
import jinja2 from babel.dates import format_date env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates')) @jinja2.contextfilter def date_filter(context, input, locale=default_fallback): if context: context_locale = context.get('locale', default_fallback) else: context_locale = locale return format_date(input, format='full', locale=context_locale) env.filters['date_filter'] = date_filter ...
Time formatting
Since Babel provides additional functions for number and time formatting, you can simply reuse the same concept and implement them accordingly.
For time formatting, you can use format_time
to format time as follows (it takes in a time object):
format_time(datetime.time(10, 30, 0), locale='en') # '10:30:00 AM' format_time(datetime.time(10, 30, 0), locale='de') # '10:30:00'
Number formatting
Likewise, you can use Babel's format_decimal
function. It works for both whole numbers and decimal points. Pass in an input number and the corresponding locale this way:
format_decimal(1.2345, locale='en') # '1.234' format_decimal(12345, locale='en') # '12,345'
Please note that Babel contains another function called format_number
, but it has been deprecated as of version 2.6.0.
We will cover the actual implementation in the Falcon Server section later on.
Falcon server
Once you are done with it, let's create a new Python file called main.py in your working directory. This file is the backend code to serve your application. It will also include all the custom filter functions that you have defined earlier.
Import
Add the following import at the top of the file:
import falcon import falcon.asgi import jinja2 import datetime import gettext import os from babel.dates import format_date, format_time from babel.numbers import format_decimal
Initialization
Next, initialize the following variables:
app = falcon.asgi.App() translations = {} default_fallback = 'en' env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates'))
app
—represents the Falcon app instancetranslations
—a dictionary which contains translation objects for each localedefault_fallback
—indicates the fallback locale for the applicationenv
—holds the Jinja2 Environment instance, which loads an HTML file from the designated location; in this case, it will load from the templates folder; you also need to enable the extension for Jinja i18n to be able to use gettext within the template files
Load translation files
You can easily load all the translation (MO) files that reside in the locales folder stored inside the translations
dictionary:
base_dir = 'locales' supported_langs = [x for x in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, x))] for lang in supported_langs: translations[lang] = gettext.translation('messages', localedir='locales', languages=[lang]) env.install_gettext_translations(translations[default_fallback])
It uses the os module, which searches for all the directories inside the locales folder. This allows you to load all your translation files automatically. Creating a new locale is just a matter of generating a new message catalog and compiling the translation file into an MO file.
Afterwards, you can instantiate and set the active locale with the install_gettext_translations
function, and pass in the desired translation object. For example, if you start with English as the base locale, you should call it as follows:
env.install_gettext_translations(translations['en'])
To change the language to German on the fly, simply call the function against with 'de' as the input parameter:
env.install_gettext_translations(translations['de'])
Filter functions
Now, it is time to define all the required custom filter functions. As mentioned previously, you need to equip it with the jinja2.contextfilter decorator:
def get_active_locale(context, locale): if context: context_locale = context.get('locale', default_fallback) else: context_locale = locale return context_locale @jinja2.contextfilter def num_filter(context, input, locale=default_fallback): context_locale = get_active_locale(context, locale) return format_decimal(input, locale=context_locale) @jinja2.contextfilter def date_filter(context, input, locale=default_fallback): context_locale = get_active_locale(context, locale) return format_date(input, format='full', locale=context_locale) @jinja2.contextfilter def time_filter(context, input, locale=default_fallback): context_locale = get_active_locale(context, locale) return format_time(input, locale=context_locale) env.filters['num_filter'] = num_filter env.filters['date_filter'] = date_filter env.filters['time_filter'] = time_filter
There is a get_active_locale
function that will return the currently active locale from the context
object. If it is not present, it will fall back to the default locale.
Each filter function has been standardized with the following parameters:
context
—contains thecontext
object. You can use this to identify the currently active locale.input
—represents the input number, Date object, or Time objectlocale
—indicates the custom locale; you can pass it to format the output based on a locale different from the currently active locale
This allows us to normally call the function within the Python application itself, apart from using it as a custom filter. It is a good practice of modularizing it to ensure that the output is the same across your application.
Falcon routes
Once you are done with it, create a new class called ExampleResource
and define a async on_get
function. It will serve as the Restful URL route for the web application:
class ExampleResource: async def on_get(self, req, resp, locale): if(locale not in supported_langs): locale = default_fallback env.install_gettext_translations(translations[locale]) # mock data data = { "event_attendee": 1234, "event_date": datetime.date(2021, 12, 4), "event_time": datetime.time(10, 30, 0) } resp.status = falcon.HTTP_200 resp.content_type = 'text/html' template = env.get_template("index.html") resp.text = template.render(**data, locale=locale)
It accepts the following input arguments:
req
—a request object which contains all the information related to the request sent by the userresp
—a response object to be returned back to the userlocale
—a path parameter representing the selected locale, which you will use as a reference when setting the active locale
There is a conditional check that sets the active locale to the default fallback if the user passes in an unsupported locale string.
class ExampleResource: async def on_get(self, req, resp, locale): if(locale not in supported_langs): locale = default_fallback env.install_gettext_translations(translations[locale]) # mock data data = { "event_attendee": 1234, "event_date": datetime.date(2021, 12, 4), "event_time": datetime.time(10, 30, 0) } resp.status = falcon.HTTP_200 resp.content_type = 'text/html' template = env.get_template("index.html") resp.text = template.render(**data, locale=locale)
For example, if the user passes in "ar" as the locale, which is not supported by your web application, it will default to the default fallback that had been defined earlier ("en" in this case).
Next, you need to change the active locale based on the locale path parameter:
class ExampleResource: async def on_get(self, req, resp, locale): if(locale not in supported_langs): locale = default_fallback env.install_gettext_translations(translations[locale]) # mock data data = { "event_attendee": 1234, "event_date": datetime.date(2021, 12, 4), "event_time": datetime.time(10, 30, 0) } resp.status = falcon.HTTP_200 resp.content_type = 'text/html' template = env.get_template("index.html") resp.text = template.render(**data, locale=locale)
Next, complete the function by adding the following code:
class ExampleResource: async def on_get(self, req, resp, locale): if(locale not in supported_langs): locale = default_fallback env.install_gettext_translations(translations[locale]) # mock data data = { "event_attendee": 1234, "event_date": datetime.date(2021, 12, 4), "event_time": datetime.time(10, 30, 0) } resp.status = falcon.HTTP_200 resp.content_type = 'text/html' template = env.get_template("index.html") resp.text = template.render(**data, locale=locale)
It will perform the following actions:
- Generate temporary mock data
- Set the response status to 200
- Get the underlying HTML text from index.html
- Render the template with Jinja2 (translations and custom filter functions happen here)
- Return the Jinja2 rendered text as HTML output
Add the final touch with the following code:
example = ExampleResource() app.add_route('/{locale}/main', example)
This will create an instance of ExampleResource and add a new route to your Falcon web application. Please note that {locale}
refers to the path parameter for the route. If you want to change the variable name to lang
, you need to modify the parameter's name in the function as well:
async def on_get(self, req, resp, lang):
Falcon will capture all the routes with the following pattern and call the ExampleResource class:
<host:port>/<any_string>/main
For example:
http://localhost:8000/en/main http://localhost:8000/ko/main http://localhost:8000/xyz/main http://localhost:8000/hahaha/main
Since we have implemented the code for the default fallback, xyz and hahaha routes will return the English webpage instead.
Redirection
Falcon will return "404 Not Found" for routes that are not part of the syntax. For example, accessing...
http://localhost:8000/
...will yield...
<error> <title>404 Not Found</title> </error>
You can easily handle this by adding a new resource and route to redirect users to the main web page:
... class RedirectResource: async def on_get(self, req, resp): raise falcon.HTTPFound(req.prefix + '/en/main') redirect = RedirectResource() app.add_route('/', redirect)
req.prefix
refers to the hostname and port of the web application. In this case, it will be:
http://localhost:8000
Run the Server
Finally, save the file and run the following command:
uvicorn main:app
By default, it will serve the application on port 8000. Head over to the following URL:
# English http://localhost:8000/en/main # German http://localhost:8000/de/main
You should see the following user interface for the German locale:
You should note that the date, time, and other number entries are formatted properly based on the locale path parameter.
Concluding our i18n walkthrough for Falcon apps
All in all, building a multilingual web application with the Falcon framework is quite simple with Babel and Jinja2. If you want to explore even more server-side i18n tutorials, feel free to have a look at:
- The Complete Flask App Tutorial on Internationalization
- Tornado Web Framework: A Step-by-Step I18n Tutorial
- CherryPy I18n Cookbook with Babel and Jinja2
If you are looking to take your localization workflow to the next level, give Phrase a try. The fastest, leanest, and most reliable localization management platform worldwide will help you streamline the i18n process with everything you need to:
- Build production-ready integrations with your development workflow
- Invite as many users as you wish to collaborate on your projects
- Edit and convert localization files with more context for higher translation quality
Sign up for a free 14-day trial, and see for yourself how it can make your life as a developer easier.