angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Building a multilanguage Flask website with Flask-Babel

Not many examples exist for multilanguage Flask. Here we follow the suggestions from the Flask documentation.

7 August 2019 Updated 30 August 2019
In Babel
post main image
Original photo unsplash.com/@jantined.

With a single language there is not really a problem, we just forget about the rest of the world and build our single language Flask application. We start getting a headache when the website must support multiple languages. What exactly is a website supporting multiple languages? How many languages will be supported, and which languages? For English there are for example en-GB and en-US. Which parts of the website must be available in all languages? What are the parts of a website anyway? I will limit myself here answering the most trivial questions. For a good introduction you also can check e.g. the Wordpress Polylang plugin guide, see below.

When you build a website without supporting multiple languages you use urls for your content, when you want to add more languages you must maintain all urls, i.e. what was already created. From a SEO point of view you better start with all the multilanguage components in place even if you doubt now if you will ever add another language.  

Language selection

When the language is in a cookie (session) proper language viewing can be a problem when cookies are disabled. With the language in the domain / url this problem is eliminated and also the website is more SEO friendly.

Option 1: language in domain extension

example.com
example.es
example.pl

Example: toyota
https://www.toyota.de
https://www.toyota.es

Option 2: language in subdomain

en.example.com
es.example.com
pl.example.com

Example: cnn
https://edition.cnn.com
https://cnnespanol.cnn.com

Option 3: language in url path

example.com/en
example.com/es
example.com/pl

Translated urls

Translated urls are very friendly, but also introduce more complexity:

https://edition.cnn.com/entertainment
https://cnnespanol.cnn.com/seccion/entretenimiento

Some websites do not have translated urls:

https://www.tesla.com/nl_NL/model3/design#battery
https://www.tesla.com/de_CH/model3/design#battery

If we want to have translated language urls, and why wouldn't we, then we also need multiple endpoints for a view function:

@pages.route('/<lang_code>/about-us', methods=['GET', 'POST'])
@pages.route('/<lang_code>/uber-uns', methods=['GET', 'POST'])
def about():
    return render_template(...)

Of course we want this somehow automated ... difficult.

Making choices

For the moment I focus on a website with the 'language id' in the url path, see above option 3, and I will not deal with translated urls. Because we are using Flask, we use Flask-Babel for our translations. Some texts, e.g. blogs, categories, tags, are in the database. We will deal with these later.

You may want a website with the main language without the 'language id' in the url path and the others languages with the 'language id' in the url path. I do not really know how to do this with Flask at the moment and also believe this very much complicates things.  Also think of SEO. Assume you want to add another language after a few years then you cannot change your urls.  Summary: I think it is not a bad idea to start with the language in the url path. 

In a first implementation I used a cookie for the language selection, now I must remove this and use the language in the url. Fortunately the Flask documentation gives good information about internationalization. I suggest you read this (I did many times). I have many blueprints, for the home page view.py I add the following to the view:

...
home_blueprint = Blueprint('home', __name__)

# lang_code in urls
@home_blueprint.url_defaults
def add_language_code(endpoint, values):
    values.setdefault('lang_code', g.lang_code)

@home_blueprint.url_value_preprocessor
def pull_lang_code(endpoint, values):

    url_lang_code_items_values = get_url_lang_code_items_values()
    url_lang_code_default_item_value = get_url_lang_code_default_item_value()

    g.lang_code = url_lang_code_default_item_value
    if values:
        if 'lang_code' in values:
            if values['lang_code'] in url_lang_code_items_values:
                g.lang_code = values.pop('lang_code', None)
            else:
                pass
...

The function get_url_lang_code_items_values() returns a list of lang_codes: en, nl, es, and function get_url_lang_code_default_item_value() returns en, so English is the default language. Then in __init__.py I register the home blueprint:

    from .blueprints.home.views import home_blueprint
    app.register_blueprint(home_blueprint, url_prefix='/<lang_code>')

What happens if we type a url without a path, or a totally random url? You will get a error message:

TypeError: homepage() got an unexpected keyword argument 'lang_code'

The Flask docs do not give a solution, but after some headaches (again) I think I found a solution to solve this using the before_request handler. In this handler I look at the request url. The path of this url is split into parts. The first part must be our language. If the first part is in our list of languages then just continue. If the page cannot be found Flask will generate a 404, page not found, which is fine. If the first part is not our list of languages, then the before_request handler returns a redirect url to the home page of the default language.

After implementing this the website was shown without styles and I got strange error messages in the Developer tools console. The solution was to exclude the static directory. So here is what is going on in the before_request handler:

    @app.before_request
    def before_request():

        #  try to handle missing lang_code in url interceptor

        url_lang_code_items_values = get_url_lang_code_items_values()
        url_lang_code_default_item_value = get_url_lang_code_default_item_value()

        # check for a valid url = starts with /lang_code/
        request_path = request.path.strip('/')
        request_path_parts = urlparse(request_path).path.split('/')
        if request.method in ['GET'] and len(request_path_parts) > 0:
            request_path_part_0 = request_path_parts[0]

            # do nothing with static urls !!!
            if request_path_part_0 != 'static' and request_path_part_0 not in url_lang_code_items_values:
                # fucked up url
                redir_url_parts = []
                redir_url_parts.append( request.url_root.strip('/') )
                redir_url_parts.append( url_lang_code_default_item_value.strip('/') )
                redir_url = '/'.join(redir_url_parts)
                return redirect(redir_url)

This works but how do we change the language?

In the base.html template I have a dropdown language selector:

	<li class="nav-item dropdown">
		<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
		{{ language_selected }}
		</a>
		<div class="dropdown-menu" aria-labelledby="navbarDropdown">
			<a class="dropdown-item" href="{{ request.path }}?lc=en_US">{{ _('EN') }}</a>
			<a class="dropdown-item" href="{{ request.path }}?lc=nl_NL">{{ _('NL') }}</a>
			<a class="dropdown-item" href="{{ request.path }}?lc=es_ES">{{ _('ES') }}</a>
		</div>
	</li>

Next we trigger the Babel language localeselector when the language is changed:

    @babel.localeselector
    def get_locale():
        request_lc = request.args.get('lc')
        if not request_lc:
            if not 'lang_code' in g:
                # use default
                g.lang_code = 'en'
                request_lc = 'en_US'
            else:
                if g.lang_code == 'es':
                    request_lc = 'es_ES'
                elif g.lang_code == 'nl':
                    request_lc = 'nl_NL'
                else:
                    request_lc = 'en_US'

        else:
            # set g.lang_code to the requested language
            if request_lc == 'nl_NL':
               g.lang_code = 'nl'
            elif request_lc == 'es_ES':
               g.lang_code = 'es'
            else:
                request_lc = 'en_US'
                g.lang_code = 'en'
            #sys.exit()
        session['lc'] = request_lc
        return request_lc

Ok, it's working now but must be optimized. Also there are still two problems at the moment:

  • When the language is changed, this is not shown in url afterwards.
    You have to go to a new page. After changing the language with the dropdown the url does not show the language in the url immediately, only on the next click. Maybe I should move the Babel code to the before_request handler?
  • Many calls to url_defaults on every call
    I would like to have the code:
    @home_blueprint.url_defaults
    @home_blueprint.url_value_preprocessor
    in the __init__.py instead of in the blueprint views.py  but this does not work.
    Why do I want this? Because I do not like duplicating the same code and I see many calls to @auth_blueprint.url etc. I believe they should be done once in __init__.py but maybe I am wrong.

 

Links / credits

Flask Series: Internationalization
https://damyanon.net/post/flask-series-internationalization/

Flask-multilang-demo
https://github.com/DusanMadar/Flask-multilang-demo

How to Easily Create a Multilingual WordPress Site
https://www.wpbeginner.com/beginners-guide/how-to-easily-create-a-multilingual-wordpress-site/

Internationalized Application URLs
https://flask.palletsprojects.com/en/1.1.x/patterns/urlprocessors/

Multilingual flask application
https://stackoverflow.com/questions/3420897/multilingual-flask-application

Set languages and locales
https://docs.microsoft.com/en-us/windows-hardware/customize/mobile/mcsf/set-languages-and-locales

Leave a comment

Comment anonymously or log in to comment.

Comments (1)

Leave a reply

Reply anonymously or log in to reply.

avatar

From where 'get_url_lang_code_items_values()' and 'get_url_lang_code_default_item_value()' come from?