Hacking Django’s makemessages for better (translations) matching in React JSX components

Have you ever found a missing translation when running Django makemessages on React JSX components? I did and it can happen as far as you don’t keep your gettext tags in the beginning of JSX modules.

That’s fine if you are starting a new React project now — you keep all your gettext() calls into variables in the beginning of the JSX components making the life easier for makemessages script but maybe not as nice for other people to read the code — but hard to catch up if there are already hundreds of modules with gettext() in place.

A solution would be writing a script to edit those JSX modules by picking all gettext() calls, store them into a variable in the beginning of the module and invoke the variable where the gettext() was being called before. But this is very prone to errors because people are not consistent regarding text formatting, variables interpolation, etc.

Facing this problem I decided to dig into Django’s makemessages and understand how the script works. In the end, makemessages is just a wrapper with some file handling making use of GNU’s library xgettext.

Knowing that, I decided to play a bit with xgettext myself passing different inputs. I ran xgettext directly passing the problematic JSX module and --language=JavaScript and in fact not all gettext() tags were picked up and shown in the output. In a desperate act I tried it with --language=Python and… yeah, all gettext() text was found.

Here is the output of passing --language=JavaScript to xgettext :

xgettext with language=JavaScript finds 3 pieces of text

And here is the output for --language=Python :

xgettext with language=Python finds 5 pieces of text

Huge difference, isn’t it? The input file is exactly the same.

What do you do next after these findings? You go back to makemessages script and try to figure out how to pass the language to the script. Slow down cowboy, you can’t do that.

The magic is all in TranslatableFile.process():

if domain == 'djangojs':
...
is_templatized = command.gettext_version < (0, 18, 3)
if is_templatized:
...
args = [
'xgettext',
'-d', domain,
'--language=%s' % ('C' if is_templatized else 'JavaScript',),
'--keyword=gettext_noop',
'--keyword=gettext_lazy',
'--keyword=ngettext_lazy:1,2',
'--keyword=pgettext:1c,2',
'--keyword=npgettext:1c,2,3',
'--output=-'
] + command.xgettext_options
args.append(work_file)
elif domain == 'django':
...
args = [
'xgettext',
'-d', domain,
'--language=Python',
'--keyword=gettext_noop',
'--keyword=gettext_lazy',
'--keyword=ngettext_lazy:1,2',
'--keyword=ugettext_noop',
'--keyword=ugettext_lazy',
'--keyword=ungettext_lazy:1,2',
'--keyword=pgettext:1c,2',
'--keyword=npgettext:1c,2,3',
'--keyword=pgettext_lazy:1c,2',
'--keyword=npgettext_lazy:1c,2,3',
'--output=-'
] + command.xgettext_options
args.append(work_file)

Django’s makemessages builds the argument list to be passed to xgettext and in the end xgettext_options is appended to the command. Also, as shown above, when domainis djangojs the language passed to xgettextis either C or JavaScript. I know, it almost makes sense since we are dealing with JavaScriptbut why force it to be like that?

All xgettext_options has inside is:

xgettext_options = ['--from-code=UTF-8', '--add-comments=Translators']
# and potentially
self.xgettext_options = self.xgettext_options[:] + ['--no-wrap']
self.xgettext_options = self.xgettext_options[:] + ['--no-location']

That’s it! I mean, since it is a class attribute and then a copy is done to build an instance attribute and add more options, we can hack it to allow the user to pass --language=<whatever> to it.

I end up creating a new hacking Django’s management command in order to pass the language provided as input to xgettext. It is not beauty because it takes advantage of arguments’ order passed to xgettext but it does the job. xgettext will be called with two --language parameter and the last one will overrule the first one. A better way would be improving makemessages directly but not now (maybe in the next steps).

Here it is:

from django.core.management.commands.makemessages import Command as MMCommand
class Command(MMCommand):
“””
This is a wrapper for the makemessages command and
it is used to force makemessages call xgettext with the language
provided as input
    The solution is really hacky and takes advantage of the fact
that in makemessages TranslatableFile process()
the options in command.xgettext_options are appended to the end
of the xgettext command.
“””
    def add_arguments(self, parser):
parser.add_argument(
‘--language’,
‘-lang’,
default=’Python’,
dest=’language’,
help=’Language to be used by xgettext’
)

super(Command, self).add_arguments(parser)
    def handle(self, *args, **options):
language = options.get(‘language’)
self.xgettext_options.append(‘--language={lang}’.format(
lang=language
))
        super(Command, self).handle(*args, **options)

Now it is possible to pass the --language to makemessages which will pass it through to xgettext. Just type (--language Python is not really needed here because Python` is the default value):

python manage.py makemessages_djangojs --domain djangojs --extension jsx --language Python

That’s it!

Like what you read? Give Hugo Sousa a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.