Repository: lingui/js-lingui
Pull request #1107 (2021-07-27 13:50:25)

feat: Cloud service providers & Translation.io
Hello LinguiJS team,

This is a PR proposal that is open for suggestions.

## Context

I'm the founder of [Translation.io](https://translation.io), a cloud service provider that helps localize applications and smooth the syncing process with translators. We decided to push best practices to keep developers and translators happy in the long run. We already support the [Ruby on Rails](https://translation.io/rails) and [Laravel](https://translation.io/laravel) stacks with a decent success.

We tried several times to support JS/React but we didn't like the existing solutions back then. Then we found LinguiJS, and it was exactly what we were looking for all that time: GetText-like easy syntax with simple source code extraction and PO format.

You really did an amazing job! 😄

## Proposal

We have decided to add Lingui as a supported framework. After a quick proof-of-concept demo and a discussion with @semoal, we thought it would be a good idea to create a new configuration option in Lingui for it to be able to work with any cloud service provider, and not only Translation.io

So the idea is to add something like this in the `.linguirc`:

```javascript
"service": {
  "name": "TranslationIO",
  "apiKey": "abcdefghijklmnopqrstuvwxyz123456"
}
```

A new `services` folder has been created for the provider specific workflows.

I created a hook in the `lingui-extract.ts` process to be able to import the service by its name, it will be cleaner when there will be multiple services.

## How it works 

After a `lingui extract`, the service sends the content of all the PO files to Translation.io (or any other provider).

* If it's the first time, the segments will be created on Translation.io (source & translation).
* If it's not the first time, new segments will be created on Translation.io, and new translations will be sent back to your target POs.

The idea would be to **just focus on your code, and never edit your PO files again**!

Source text (usually English) will evolve in your code, and your translations will evolve on Translation.io and pushed back into your code at each `extract` (it works without conflict even if you work on multiple branches, with a process similar to `obsolete` and `--clean`).

Big advantages of a cloud provider is [auto-translation](https://translation.io/blog/automatic-translation-with-google-translate), [interpolation and HTML highlighting](https://translation.io/blog/highlighting-of-html-tags-and-interpolated-variables), (+ warning if missing), [fine-grained collaboration roles](https://translation.io/blog/fine-grained-authorization-and-role-management), history of changes, automatic notification of translators, or ICU plural management (see below).

## Demo

Here is a small demo where you can see the initialization process, the sync process and the translation interface with:

 * Variable interpolation & HTML tags management.
 * ICU Plural management with examples for French and Czech (Czech has 3 plurals).

https://user-images.githubusercontent.com/529357/127163373-3e0a5fb6-0e8b-4e19-8c62-79100b0f9a4a.mp4

## Final notes

I'm convinced that a better symbiosis between LinguiJS and cloud solutions would be beneficial for the library popularity, for LinguiJS users, and of course for cloud solutions themselves.

If you want to test this PR, feel free to: 

 1. Create an account in [Translation.io](https://translation.io).
 2. Create a Lingui project using this magic link https://translation.io/new?lingui
 3. Check the open-source checkbox and use `https://github.com/lingui/js-lingui` as link, it will provide you with a free unlimited access like any other open-source projects using Translation.io.

-----

Any feedback would be of course greatly appreciated 🙂

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/lingui-js/js-lingui/3QdZBm7xJRVfML6feYh7MZ7d1VZN](https://vercel.com/lingui-js/js-lingui/3QdZBm7xJRVfML6feYh7MZ7d1VZN)  
✅ Preview: *In Progress*



Patch:
diff --git a/docs/ref/cli.rst b/docs/ref/cli.rst
index 72836fff..0fdeb0da 100644
--- a/docs/ref/cli.rst
+++ b/docs/ref/cli.rst
@@ -44,7 +44,7 @@ Commands
 ``extract``
 -----------
 
-.. lingui-cli:: extract [files...] [--clean] [--overwrite] [--format <format>] [--locale <locale>] [--convert-from <format>] [--verbose] [--watch]
+.. lingui-cli:: extract [files...] [--clean] [--overwrite] [--format <format>] [--locale <locale>] [--convert-from <format>] [--verbose] [--watch [--debounce <delay>]]
 
 This command extracts messages from source files and creates a message catalog for
 each language using the following steps:
@@ -109,6 +109,11 @@ Watches only for changes in files in paths defined in config file or in the comm
 
 Remember to use this only in development as this command do not cleans obsolete translations.
 
+.. lingui-cli-option:: --debounce <delay>
+
+Debounce, when used with ``--debounce <delay>``, delays extraction for ``<delay>`` milliseconds,
+bundling multiple file changes together.
+
 ``extract-template``
 --------------------
 
@@ -123,7 +128,7 @@ Prints additional information.
 ``compile``
 -----------
 
-.. lingui-cli:: compile [--strict] [--format <format>] [--verbose] [--namespace <namespace>] [--watch]
+.. lingui-cli:: compile [--strict] [--format <format>] [--verbose] [--namespace <namespace>] [--watch [--debounce <delay>]]
 
 This command compiles message catalogs in :conf:`localeDir` and outputs
 minified Javascript files. Each message is replaced with a function
@@ -158,3 +163,8 @@ Generates a {compiledFile}.d.ts and the compiled file is generated using the ext
 Watch mode.
 
 Watches only for changes in locale files in your defined locale catalogs. For ex. ``locales\en\messages.po``
+
+.. lingui-cli-option:: --debounce <delay>
+
+Debounce, when used with ``--debounce <delay>``, delays compilation for ``<delay>`` milliseconds,
+to avoid compiling multiple times for subsequent file changes.
diff --git a/docs/ref/core.rst b/docs/ref/core.rst
index ee465679..b10ed5a1 100644
--- a/docs/ref/core.rst
+++ b/docs/ref/core.rst
@@ -161,6 +161,65 @@ Reference
          // Message with custom messageId
          i18n._("msg.id", { name: "Tom" }, { defaults: "My name is {name}" })
 
+   .. js:method:: date(value: string | Date[, format: Intl.DateTimeFormatOptions])
+
+      :returns: Formatted date string
+
+      Format a date using the conventional format for the active language.
+
+      ``date`` is a Date object to be formatted. When ``date`` is a string, the Date object is created by ``new Date(date)``.
+
+      ``format`` is an object passed to the ``options`` argument of the `Intl.DateTimeFormat constructor <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat>`_ (optional).
+
+      .. code-block:: js
+
+         import { i18n } from "@lingui/core"
+
+         const d = new Date("2021-07-23T16:23:00")
+
+         i18n.activate("en")
+         i18n.date(d)
+         // Returns "7/23/2021"
+
+         i18n.date(d, { timeStyle: "medium"})
+         // Returns "4:23:00 PM"
+
+         i18n.date(d, { dateStyle: "medium", timeStyle: "medium"})
+         // Returns "Jul 23, 2021, 4:23:00 PM"
+
+         i18n.activate("cs")
+         i18n.date(d)
+         // Returns "23. 7. 2021"
+
+
+   .. js:method:: number(value: number[, format: Intl.NumberFormatOptions])
+
+      :returns: Formatted number string
+
+      Format a number using the conventional format for the active language.
+
+      ``number`` is a number to be formatted.
+
+      ``format`` is an object passed to the ``options`` argument of the `Intl.NumberFormat constructor <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat>`_ (optional).
+
+      .. code-block:: js
+
+         import { i18n } from "@lingui/core"
+
+         i18n.activate("en")
+         i18n.number(12345.678)
+         // Returns "12,345.678"
+
+         i18n.number(12345.678, { style: "currency", currency: "USD"})
+         // Returns "$12,345.68"
+
+         i18n.activate("cs")
+         i18n.number(12345.678)
+         // Returns "12 345,678"
+
+         i18n.number(12345.678, { style: "currency", currency: "CZK"})
+         // Returns "12 345,68 Kč"
+
 .. js:function:: setupI18n([options])
 
    :returns: instance of I18n
diff --git a/docs/ref/macro.rst b/docs/ref/macro.rst
index 29c6d144..3f4f05d8 100644
--- a/docs/ref/macro.rst
+++ b/docs/ref/macro.rst
@@ -70,7 +70,8 @@ transformed into ``i18n._`` call.
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   i18n._(/*i18n*/{ id: "Attachment {name} saved", values: { name }})
+   /*i18n*/
+   i18n._("Attachment {name} saved", { name })
 
 .. note::
 
@@ -111,33 +112,23 @@ Examples of JS macros
 +=============================================================+====================================================================+
 | .. code-block:: js                                          | .. code-block:: js                                                 |
 |                                                             |                                                                    |
-|    t`Refresh inbox`                                         |    i18n._(/*i18n*/{                                                |
-|                                                             |      id: "Refresh inbox"                                           |
-|                                                             |    })                                                              |
+|    t`Refresh inbox`                                         |    /*i18n*/                                                        |
+|                                                             |    i18n._("Refresh inbox")                                         |
 +-------------------------------------------------------------+--------------------------------------------------------------------+
 | .. code-block:: js                                          | .. code-block:: js                                                 |
 |                                                             |                                                                    |
-|    t`Attachment ${name} saved`                              |    i18n._(/*i18n*/{                                                |
-|                                                             |      id: "Attachment {name} saved",                                |
-|                                                             |      values: { name }                                              |
-|                                                             |    })                                                              |
+|    t`Attachment ${name} saved`                              |    /*i18n*/                                                        |
+|                                                             |    i18n._("Attachment {name} saved", { name })                     |
 +-------------------------------------------------------------+--------------------------------------------------------------------+
 | .. code-block:: js                                          | .. code-block:: js                                                 |
 |                                                             |                                                                    |
-|    plural(count, {                                          |    i18n._(/*i18n*/{                                                |
-|       one: "Message",                                       |      id: "{count, plural, one {Message} other {Messages}}",        |
-|       other: "Messages"                                     |      values: { count }                                             |
+|    plural(count, {                                          |    /*i18n*/                                                        |
+|       one: "Message",                                       |    i18n._("{count, plural, one {Message} other {Messages}}", {     |
+|       other: "Messages"                                     |      count                                                         |
 |    })                                                       |    })                                                              |
 +-------------------------------------------------------------+--------------------------------------------------------------------+
 | .. code-block:: js                                          | .. code-block:: js                                                 |
 |                                                             |                                                                    |
-|    t`Today is ${date(today)}`                               |    i18n._(/*i18n*/{                                                |
-|                                                             |      id: "Today is {0}",                                           |
-|                                                             |      values: { 0: date(today) }                                    |
-|                                                             |    })                                                              |
-+-------------------------------------------------------------+--------------------------------------------------------------------+
-| .. code-block:: js                                          | .. code-block:: js                                                 |
-|                                                             |                                                                    |
 |    t({                                                      |    i18n._(/*i18n*/{                                                |
 |       id: "msg.refresh",                                    |      id: "msg.refresh",                                            |
 |       message: "Refresh inbox"                              |      message: "Refresh inbox"                                      |
@@ -168,19 +159,12 @@ Examples of JSX macros
 |                                                             |                                                                    |
 |    <Plural                                                  |    <Trans                                                          |
 |       value={count}                                         |       id="{count, plural, one { Message} other {Messages}}"        |
-|       one="Message"                                         |       values={{ name }}                                            |
+|       one="Message"                                         |       values={{ count }}                                           |
 |       other="Messages"                                      |    />                                                              |
 |    />                                                       |                                                                    |
 +-------------------------------------------------------------+--------------------------------------------------------------------+
 | .. code-block:: jsx                                         | .. code-block:: jsx                                                |
 |                                                             |                                                                    |
-|    <Trans>Today is {date(today)}</Trans>                    |    <Trans                                                          |
-|                                                             |       id="Today is {0}"                                            |
-|                                                             |       values={{ 0: date(today) }}                                  |
-|                                                             |    />                                                              |
-+-------------------------------------------------------------+--------------------------------------------------------------------+
-| .. code-block:: jsx                                         | .. code-block:: jsx                                                |
-|                                                             |                                                                    |
 |    <Trans id="msg.refresh">                                 |    <Trans                                                          |
 |       Refresh inbox                                         |       id="msg.refresh"                                             |
 |    </Trans>                                                 |       message="Refresh inbox"                                      |
@@ -242,9 +226,9 @@ in ICU MessageFormat:
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: 'Hello World',
-   })
+   const message =
+   /*i18n*/
+   i18n._("Hello World")
 
 Message variables are supported:
 
@@ -256,9 +240,10 @@ Message variables are supported:
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: 'My name is {name}',
-     values: { name }
+   const message =
+   /*i18n*/
+   i18n._("My name is {name}", {
+     name
    })
 
 In fact, any expression can be used inside template literal. However, only
@@ -268,15 +253,17 @@ other expressions are referenced by numeric index:
 .. code-block:: jsx
 
    import { t } from "@lingui/macro"
-   const message = t`Today is ${date(name)}`
+   const message = t`Today is ${new Date()}`
 
    // ↓ ↓ ↓ ↓ ↓ ↓
 
-   import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: 'Today is {0}',
-     values: { 0: date(name) }
-   })
+   import { i18n } from "@lingui/core";
+
+   const message =
+   /*i18n*/
+   i18n._("Today is {0}", {
+     0: new Date()
+   });
 
 It's also possible to pass custom ``id`` and ``comment`` for translators by
 calling ``t`` macro with a message descriptor:
@@ -296,7 +283,8 @@ calling ``t`` macro with a message descriptor:
    const message = i18n._(/*i18n*/{
       id: 'msg.hello',
       comment: 'Greetings at the homepage',
-      message: 'Hello {name}'
+      message: 'Hello {name}',
+      values: { name }
    })
 
 In this case the ``message`` is used as a default message and it's transformed
@@ -315,7 +303,8 @@ as if it were wrapped in ``t`` macro. ``message`` also accepts any other macros:
    import { i18n } from "@lingui/core"
    const message = i18n._(/*i18n*/{
       id: 'msg.plural',
-      message: '{value, plural, one {...} other {...}}'
+      message: '{value, plural, one {...} other {...}}',
+      values: { value }
    })
 
 plural
@@ -344,9 +333,10 @@ used in the source code depends on your source locale (e.g. English has only
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: '{count, plural, one {# Book} other {# Books}}',
-     values: { count }
+   const message =
+   /*i18n*/
+   i18n._('{count, plural, one {# Book} other {# Books}}', {
+     count
    })
 
 If you need to add variables to plural form, you can use template string literals.
@@ -364,9 +354,10 @@ are transformed automatically:
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: '{count, plural, one {{name} has # friend} other {{name} has # friends}}',
-     values: { count }
+   const message =
+   /*i18n*/
+   i18n._('{count, plural, one {{name} has # friend} other {{name} has # friends}}', {
+     count, name
    })
 
 Plurals can also be nested to form complex messages. Here's an example using
@@ -390,19 +381,20 @@ two counters:
    // Generated message was wrapped for better readability
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: `{count, plural,
+   const message =
+   /*i18n*/
+   i18n._(`{numBooks, plural,
             one {{numArticles, plural,
                one {1 book and 1 article}
                other {1 book and {numArticles} articles}
-            }
+            }}
             other {{numArticles, plural,
                one {{numBooks} books and 1 article}
-               other {{numBooks} books and {numArticles} articles
-            }
+               other {{numBooks} books and {numArticles} articles}
+            }}
          }`,
-     values: { numBooks numArticles }
-   })
+     { numBooks, numArticles }
+   )
 
 .. note::
 
@@ -440,9 +432,10 @@ cardinal plural forms it uses ordinal forms:
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: '{count, selectOrdinal, one {1st} two {2nd} few {3rd} other {#th}}',
-     values: { count }
+   const message =
+   /*i18n*/
+   i18n._('{count, selectOrdinal, one {1st} two {2nd} few {3rd} other {#th}}', {
+     count
    })
 
 .. important::
@@ -474,9 +467,10 @@ provided in ``options`` object which key matches exactly ``value``:
    // ↓ ↓ ↓ ↓ ↓ ↓
 
    import { i18n } from "@lingui/core"
-   const message = i18n._(/*i18n*/{
-     id: '{gender, select, male {he} female {she} other {they}}',
-     values: { gender }
+   const message =
+   /*i18n*/
+   i18n._('{gender, select, male {he} female {she} other {they}}', {
+     gender
    })
 
 .. important::
diff --git a/docs/tutorials/react.rst b/docs/tutorials/react.rst
index 5a759e19..0a13811f 100644
--- a/docs/tutorials/react.rst
+++ b/docs/tutorials/react.rst
@@ -14,30 +14,32 @@ We're going to translate the following app:
 
 .. code-block:: jsx
 
-   // index.js
+   // src/index.js
    import React from 'react'
    import { render } from 'react-dom'
-   import Inbox from './Inbox.js'
+   import Inbox from './Inbox'
 
    const App = () => <Inbox />
 
-   render(<App />, document.getElementById('app'))
+   render(<App />, document.getElementById('root'))
 
 .. code-block:: jsx
 
-   // Inbox.js
+   // src/Inbox.js
    import React from 'react'
 
-   const Inbox = ({ messages, markAsRead, user }) => {
+   export default function Inbox() {
+      const messages = [{}, {}]
       const messagesCount = messages.length
-      const { name, lastLogin } = user
+      const lastLogin = new Date()
+      const markAsRead = () => { alert('Marked as read.') }
 
       return (
          <div>
            <h1>Message Inbox</h1>
 
            <p>
-             See all <Link to="/unread">unread messages</Link>{" or "}
+             See all <a href="/unread">unread messages</a>{" or "}
              <a onClick={markAsRead}>mark them</a> as read.
            </p>
 
@@ -50,7 +52,7 @@ We're going to translate the following app:
            </p>
 
            <footer>
-             Last login on {lastLogin}.
+             Last login on {lastLogin.toLocaleDateString()}.
            </footer>
          </div>
       )
@@ -80,14 +82,14 @@ Let's add all required imports and wrap our app inside :component:`I18nProvider`
 
 .. code-block:: jsx
 
-   // index.js
+   // src/index.js
    import React from 'react'
    import { render } from 'react-dom'
 
    import { i18n } from '@lingui/core'
    import { I18nProvider } from '@lingui/react'
-   import { messages } from './locales/en/messages.js'
-   import Inbox from './Inbox.js'
+   import { messages } from './locales/en/messages'
+   import Inbox from './Inbox'
 
    i18n.load('en', messages)
    i18n.activate('en')
@@ -98,7 +100,7 @@ Let's add all required imports and wrap our app inside :component:`I18nProvider`
      </I18nProvider>
    )
 
-   render(<App />, document.getElementById('app'))
+   render(<App />, document.getElementById('root'))
 
 .. hint::
 
@@ -133,7 +135,7 @@ macro:
 
 .. code-block:: jsx
 
-   import { Trans } from '@lingui/macro';
+   import { Trans } from '@lingui/macro'
    
    <h1><Trans>Message Inbox</Trans></h1>
 
@@ -191,7 +193,11 @@ We need here to fix the configuration. Create a ``.linguirc`` file with
 .. code-block:: json
 
    {
-      "locales": ["cs", "en"]
+     "locales": ["cs", "en"],
+     "catalogs": [{
+       "path": "src/locales/{locale}/messages",
+       "include": ["src"]
+     }]
    }
 
 After fixing configuration, let's run :cli:`extract` command again::
@@ -210,23 +216,33 @@ After fixing configuration, let's run :cli:`extract` command again::
    (use "lingui compile" to compile catalogs for production)
 
 Nice! It seems it worked, we have two message catalogs (one per each locale) with
-1 message each. Let's take a look at file ``locale/cs/messages.json``
+1 message each. Let's take a look at file ``src/locales/cs/messages.po``
 
-.. code-block:: json
+.. code-block:: po
+
+   msgid ""
+   msgstr ""
+   "POT-Creation-Date: 2021-07-22 21:44+0900\n"
+   "MIME-Version: 1.0\n"
+   "Content-Type: text/plain; charset=utf-8\n"
+   "Content-Transfer-Encoding: 8bit\n"
+   "X-Generator: @lingui/cli\n"
+   "Language: cs\n"
+
+   #: src/Inbox.js:12
+   msgid "Message Inbox"
+   msgstr ""
 
-   {
-      "Message Inbox": ""
-   }
 
 That's the message we've wrapped inside :jsxmacro:`Trans` macro!
 
 Let's add a Czech translation:
 
-.. code-block:: json
+.. code-block:: po
 
-   {
-      "Message Inbox": "Příchozí zprávy"
-   }
+   #: src/Inbox.js:12
+   msgid "Message Inbox"
+   msgstr "Příchozí zprávy"
 
 If we run :cli:`extract` command again, we'll see that all Czech messages are translated::
 
@@ -252,24 +268,28 @@ to compile them. As you see in the help in command output, we use :cli:`compile`
    Compiling message catalogs…
    Done!
 
-What just happened? If you look inside ``locales`` directory, you'll see there's a
-new file for each locale: ``<locale>.js``. This file contains compiled message catalog.
+What just happened? If you look inside ``locales/<locale>`` directory, you'll see there's a
+new file for each locale: ``messages.js``. This file contains compiled message catalog.
 
 Let's load this file into our app and set active language to ``cs``:
 
 .. code-block:: jsx
-   :emphasize-lines: 5,10
+   :emphasize-lines: 7,8,11-15
 
-   // index.js
+   // src/index.js
    import React from 'react'
    import { render } from 'react-dom'
-   import Inbox from './Inbox.js'
 
-   import { I18nProvider } from '@lingui/react'
    import { i18n } from '@lingui/core'
-
-   import catalogCs from './locales/cs.js'
-   i18n.load('cs', catalogCs.messages)
+   import { I18nProvider } from '@lingui/react'
+   import { messages as enMessages } from './locales/en/messages'
+   import { messages as csMessages } from './locales/cs/messages'
+   import Inbox from './Inbox'
+
+   i18n.load({
+     en: enMessages,
+     cs: csMessages,
+   })
    i18n.activate('cs')
 
    const App = () => (
@@ -278,7 +298,7 @@ Let's load this file into our app and set active language to ``cs``:
      </I18nProvider>
    )
 
-   render(<App />, document.getElementById('app'))
+   render(<App />, document.getElementById('root'))
 
 When we run the app, we see the inbox header is translated into Czech.
 
@@ -313,7 +333,7 @@ variables, some HTML and components inside:
 .. code-block:: jsx
 
    <p>
-      See all <Link to="/unread">unread messages</Link>{" or "}
+      See all <a href="/unread">unread messages</a>{" or "}
       <a onClick={markAsRead}>mark them</a> as read.
    </p>
 
@@ -324,7 +344,7 @@ of the paragraph in :jsxmacro:`Trans` and let the macro do the magic:
 
    <p>
       <Trans>
-         See all <Link to="/unread">unread messages</Link>{" or "}
+         See all <a href="/unread">unread messages</a>{" or "}
          <a onClick={markAsRead}>mark them</a> as read.
       </Trans>
    </p>
@@ -480,8 +500,8 @@ Let's move on and add i18n to another text in our component:
    <p>
       {
          messagesCount === 1
-            ? "There's {messagesCount} message in your inbox."
-            : "There are {messagesCount} messages in your inbox."
+            ? `There's ${messagesCount} message in your inbox.`
+            : `There are ${messagesCount} messages in your inbox.`
       }
    </p>
 
@@ -499,6 +519,40 @@ languages have up to 6 plural forms and some don't have plurals at all!
    Plural forms for all languages can be found in the
    `CLDR repository <http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html>`_.
 
+Let's load plural data into our app:
+
+.. code-block:: jsx
+   :emphasize-lines: 7,12-15
+
+   // src/index.js
+   import React from 'react'
+   import { render } from 'react-dom'
+
+   import { i18n } from '@lingui/core'
+   import { I18nProvider } from '@lingui/react'
+   import { en, cs } from 'make-plural/plurals'
+   import { messages as enMessages } from './locales/en/messages'
+   import { messages as csMessages } from './locales/cs/messages'
+   import Inbox from './Inbox'
+
+   i18n.loadLocaleData({
+     en: { plurals: en },
+     cs: { plurals: cs },
+   })
+   i18n.load({
+     en: enMessages,
+     cs: csMessages,
+   })
+   i18n.activate('cs')
+
+   const App = () => (
+     <I18nProvider i18n={i18n}>
+       <Inbox />
+     </I18nProvider>
+   )
+
+   render(<App />, document.getElementById('root'))
+
 English plural rules
 --------------------
 
@@ -519,6 +573,8 @@ the right plural form:
 
 .. code-block:: jsx
 
+   import { Trans, Plural } from '@lingui/macro'
+
    <p>
       <Plural
          value={messagesCount}
@@ -674,20 +730,33 @@ The last message in our component is again a bit specific:
 .. code-block:: jsx
 
    <footer>
-      Last login on {lastLogin}.
+      Last login on {lastLogin.toLocaleDateString()}.
    </footer>
 
 ``lastLogin`` is a date object and we need to format it properly. Dates are
 formatted differently in different languages, but we don't have
-to do this manually. The heavylifting is done by the `Intl object <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl>`_, we'll just use :jsmacro:`date` macro:
+to do this manually. The heavylifting is done by the `Intl object <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl>`_, we'll just use ``i18n.date`` function.
+The ``i18n`` object can be accessed by :js:func:`useLingui` hook:
 
 .. code-block:: jsx
 
-   <footer>
-      <Trans>
-         Last login on {date(lastLogin)} />.
-      </Trans>
-   </footer>
+   import { useLingui } from '@lingui/react'
+
+   export default function Inbox() {
+     const { i18n } = useLingui()
+     // ...
+
+     return (
+       <div>
+         {/* ... */}
+         <footer>
+           <Trans>
+             Last login on {i18n.date(lastLogin)}.
+           </Trans>
+         </footer>
+       </div>
+     )
+   }
 
 This will format the date using the conventional format for the active language.
 
@@ -698,40 +767,44 @@ After all modifications, the final component with i18n looks like this:
 
 .. code-block:: jsx
 
-   // Inbox.js
+   // src/Inbox.js
    import React from 'react'
    import { Trans, Plural } from '@lingui/macro'
    import { useLingui } from '@lingui/react'
 
-   const Inbox = ({ messages, markAsRead, user }) => {
+   export default function Inbox() {
      const { i18n } = useLingui()
+     const messages = [{}, {}]
      const messagesCount = messages.length
-     const { name, lastLogin } = user
+     const lastLogin = new Date()
+     const markAsRead = () => { alert('Marked as read.') }
 
      return (
-         <div>
-           <h1><Trans>Message Inbox</Trans></h1>
-
-           <p>
-             <Trans>
-               See all <Link to="/unread">unread messages</Link>{" or "}
-               <a onClick={markAsRead}>mark them</a> as read.
-             </Trans>
-           </p>
-
-           <p>
-             <Plural
-               value={messagesCount}
-               one="There's # message in your inbox."
-               other="There are # messages in your inbox."
-             />
-           </p>
-
-           <footer>
-             <Trans>Last login on {i18n.date(lastLogin)} />.</Trans>
-           </footer>
-         </div>
-       )
+       <div>
+         <h1><Trans>Message Inbox</Trans></h1>
+
+         <p>
+           <Trans>
+              See all <a href="/unread">unread messages</a>{" or "}
+              <a onClick={markAsRead}>mark them</a> as read.
+           </Trans>
+         </p>
+
+         <p>
+           <Plural
+              value={messagesCount}
+              one="There's # message in your inbox"
+              other="There are # messages in your inbox"
+           />
+         </p>
+
+         <footer>
+           <Trans>
+             Last login on {i18n.date(lastLogin)}.
+           </Trans>
+         </footer>
+       </div>
+     )
    }
 
 That's all for this tutorial! Checkout the reference documentation or various guides
diff --git a/package.json b/package.json
index e694612a..10d4ff5c 100644
--- a/package.json
+++ b/package.json
@@ -125,7 +125,7 @@
     },
     {
       "path": "./packages/remote-loader/build/cjs/remote-loader.production.min.js",
-      "limit": "5 kB"
+      "limit": "1 MB"
     }
   ]
 }
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 6a4f4892..ebd610cc 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -29,6 +29,7 @@
     "LICENSE",
     "README.md",
     "api",
+    "services",
     "lingui.js",
     "lingui-*.js"
   ],
diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts
index d6a4d792..46124e67 100644
--- a/packages/cli/src/api/catalog.ts
+++ b/packages/cli/src/api/catalog.ts
@@ -296,7 +296,7 @@ export class Catalog {
     }
 
     const getMultipleFallbacks = (locale) => {
-      const fL = fallbackLocales[locale]
+      const fL = fallbackLocales && fallbackLocales[locale]
 
       // some probably the fallback will be undefined, so just search by locale
       if (!fL) return null
@@ -318,7 +318,9 @@ export class Catalog {
       // We search in fallbackLocales as dependent of each locale
       getMultipleFallbacks(locale) ||
       // Get translation in fallbackLocales.default (if any)
-      (fallbackLocales.default && getTranslation(fallbackLocales.default)) ||
+      (fallbackLocales &&
+        fallbackLocales.default &&
+        getTranslation(fallbackLocales.default)) ||
       // Get message default
       catalogs[locale][key].defaults ||
       // If sourceLocale is either target locale of fallback one, use key
@@ -514,7 +516,7 @@ export function getCatalogs(config: LinguiConfig) {
 
     const patterns = include.map((path) => path.replace(NAME, "*"))
     const candidates = glob.sync(
-      patterns.length > 1 ? `{${patterns.join(",")}` : patterns[0],
+      patterns.length > 1 ? `{${patterns.join(",")}}` : patterns[0],
       {
         ignore: exclude,
         mark: true,
diff --git a/packages/cli/src/api/compile.ts b/packages/cli/src/api/compile.ts
index 16664cda..95e00bab 100644
--- a/packages/cli/src/api/compile.ts
+++ b/packages/cli/src/api/compile.ts
@@ -5,6 +5,8 @@ import * as R from "ramda"
 
 import pseudoLocalize from "./pseudoLocalize"
 
+
+const INVALID_OBJECT_KEY_REGEX = /^(\d+[a-zA-Z]|[a-zA-Z]+\d)(\d|[a-zA-Z])*/
 export type CompiledCatalogNamespace = "cjs" | "es" | "ts" | string
 
 type CompiledCatalogType = {
@@ -159,7 +161,8 @@ function processTokens(tokens) {
         const inlineTokens = processTokens(item.tokens)
         formatProps.push(
           t.objectProperty(
-            t.identifier(item.key),
+            // if starts with number must be wrapped with quotes
+            INVALID_OBJECT_KEY_REGEX.test(item.key) ? t.stringLiteral(item.key) : t.identifier(item.key),
             isString(inlineTokens)
               ? t.stringLiteral(inlineTokens)
               : inlineTokens
diff --git a/packages/cli/src/api/extractors/index.ts b/packages/cli/src/api/extractors/index.ts
index cb5d23b0..5b735ccb 100644
--- a/packages/cli/src/api/extractors/index.ts
+++ b/packages/cli/src/api/extractors/index.ts
@@ -27,7 +27,15 @@ export default function extract(
 ): boolean {
   const extractorsToExtract = options.extractors ?? DEFAULT_EXTRACTORS
 
-  return extractorsToExtract.some((ext) => {
+  return extractorsToExtract.some((e) => {
+    let ext: ExtractorType = e;
+    if (typeof e === "string") {
+      // in case of the user using require.resolve in their extractors, we require that module
+      ext = require(e)
+      if ((ext as any).default) {
+        ext = (ext as any).default
+      }
+    }
     if (!ext.match(filename)) return false
 
     let spinner
diff --git a/packages/cli/src/api/formats/po-gettext.ts b/packages/cli/src/api/formats/po-gettext.ts
index 9f99017a..7b86aa37 100644
--- a/packages/cli/src/api/formats/po-gettext.ts
+++ b/packages/cli/src/api/formats/po-gettext.ts
@@ -222,7 +222,7 @@ const convertPluralsToICU = (items: POItemType[], lang?: string) => {
     const messageKey = getMessageKey(item)
 
     // Messages without multiple translations (= plural cases) need no further processing.
-    if (translationCount <= 1) {
+    if (translationCount <= 1 && !item.msgid_plural) {
       return
     }
 
diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts
index f9f4de00..bcbd7dfd 100644
--- a/packages/cli/src/lingui-compile.ts
+++ b/packages/cli/src/lingui-compile.ts
@@ -10,7 +10,7 @@ import { getConfig, LinguiConfig } from "@lingui/conf"
 import { getCatalogs } from "./api/catalog"
 import { createCompiledCatalog } from "./api/compile"
 import { helpRun } from "./api/help"
-import { getFormat } from "./api";
+import { getFormat } from "./api"
 
 const noMessages: (catalogs: Object[]) => boolean = R.pipe(
   R.map(R.isEmpty),
@@ -50,19 +50,13 @@ function command(config: LinguiConfig, options) {
     }
 
     catalogs.forEach((catalog) => {
-      const messages = catalog.getTranslations(
-        locale,
-        {
-          fallbackLocales: config.fallbackLocales,
-          sourceLocale: config.sourceLocale,
-        }
-      )
+      const messages = catalog.getTranslations(locale, {
+        fallbackLocales: config.fallbackLocales,
+        sourceLocale: config.sourceLocale,
+      })
 
       if (!options.allowEmpty) {
-        const missingMsgIds = R.pipe(
-          R.pickBy(R.isNil),
-          R.keys,
-        )(messages)
+        const missingMsgIds = R.pipe(R.pickBy(R.isNil), R.keys)(messages)
 
         if (missingMsgIds.length > 0) {
           console.error(
@@ -77,7 +71,9 @@ function command(config: LinguiConfig, options) {
             console.error(chalk.red("Missing translations:"))
             missingMsgIds.forEach((msgId) => console.log(msgId))
           } else {
-            console.error(chalk.red(`Missing ${missingMsgIds.length} translation(s)`))
+            console.error(
+              chalk.red(`Missing ${missingMsgIds.length} translation(s)`)
+            )
           }
           console.error()
           process.exit(1)
@@ -87,7 +83,9 @@ function command(config: LinguiConfig, options) {
       if (doMerge) {
         mergedCatalogs = { ...mergedCatalogs, ...messages }
       } else {
-        const namespace = options.typescript ? "ts" : options.namespace || config.compileNamespace
+        const namespace = options.typescript
+          ? "ts"
+          : options.namespace || config.compileNamespace
         const compiledCatalog = createCompiledCatalog(locale, messages, {
           strict: false,
           namespace,
@@ -135,6 +133,10 @@ if (require.main === module) {
       "Specify namespace for compiled bundle. Ex: cjs(default) -> module.exports, es -> export, window.test -> window.test"
     )
     .option("--watch", "Enables Watch Mode")
+    .option(
+      "--debounce <delay>",
+      "Debounces compilation for given amount of milliseconds"
+    )
     .on("--help", function () {
       console.log("\n  Examples:\n")
       console.log(
@@ -159,42 +161,60 @@ if (require.main === module) {
     config.format = program.format
   }
 
-  const compile = () => command(config, {
-    verbose: program.watch || program.verbose || false,
-    allowEmpty: !program.strict,
-    typescript: program.typescript || config.compileNamespace === "ts" || false,
-    namespace: program.namespace, // we want this to be undefined if user does not specify so default can be used
-  })
+  const compile = () =>
+    command(config, {
+      verbose: program.watch || program.verbose || false,
+      allowEmpty: !program.strict,
+      typescript:
+        program.typescript || config.compileNamespace === "ts" || false,
+      namespace: program.namespace, // we want this to be undefined if user does not specify so default can be used
+    })
+
+  let debounceTimer: NodeJS.Timer
+  const dispatchCompile = () => {
+    // Skip debouncing if not enabled
+    if (!program.debounce) return compile()
+
+    // CLear the previous timer if there is any, and schedule the next
+    debounceTimer && clearTimeout(debounceTimer)
+    debounceTimer = setTimeout(() => compile(), program.debounce)
+  }
 
   // Check if Watch Mode is enabled
   if (program.watch) {
     const NAME = "{name}"
     const LOCALE = "{locale}"
 
-    console.info(chalk.bold("Initializing Watch Mode...")) 
+    console.info(chalk.bold("Initializing Watch Mode..."))
 
-    const catalogs = getCatalogs(config);
-    let paths = []; 
-    const catalogExtension = getFormat(config.format).catalogExtension;
+    const catalogs = getCatalogs(config)
+    let paths = []
+    const catalogExtension = getFormat(config.format).catalogExtension
 
     config.locales.forEach((locale) => {
       catalogs.forEach((catalog) => {
-        paths.push(`${catalog.path.replace(LOCALE, locale).replace(NAME, "*")}${catalogExtension}`)
-      })  
+        paths.push(
+          `${catalog.path
+            .replace(LOCALE, locale)
+            .replace(NAME, "*")}${catalogExtension}`
+        )
+      })
     })
 
     const watcher = chokidar.watch(paths, {
       persistent: true,
-    });
+    })
 
     const onReady = () => {
       console.info(chalk.green.bold("Watcher is ready!"))
-      watcher.on('add', () => compile()).on('change', () => compile());
-    };
+      watcher
+        .on("add", () => dispatchCompile())
+        .on("change", () => dispatchCompile())
+    }
 
-    watcher.on('ready', () => onReady());
+    watcher.on("ready", () => onReady())
   } else {
-    const results = compile();
+    const results = compile()
 
     if (!results) {
       process.exit(1)
diff --git a/packages/cli/src/lingui-extract.ts b/packages/cli/src/lingui-extract.ts
index b88eb9ec..3acc97f2 100644
--- a/packages/cli/src/lingui-extract.ts
+++ b/packages/cli/src/lingui-extract.ts
@@ -1,5 +1,5 @@
 import chalk from "chalk"
-import chokidar from "chokidar"
+import chokidar, { watch } from "chokidar"
 import program from "commander"
 
 import { getConfig, LinguiConfig } from "@lingui/conf"
@@ -37,7 +37,7 @@ export default function command(
   options.verbose && console.error("Extracting messages from source files…")
 
   const catalogs = getCatalogs(config)
-  const catalogStats: { [path: string]: AllCatalogsType }  = {}
+  const catalogStats: { [path: string]: AllCatalogsType } = {}
   catalogs.forEach((catalog) => {
     catalog.make({
       ...options,
@@ -65,7 +65,14 @@ export default function command(
       `(use "${chalk.yellow(
         helpRun("compile")
       )}" to compile catalogs for production)`
-    ) 
+    )
+  }
+
+  // If service key is present in configuration, synchronize with cloud translation platform
+  if (typeof config.service === 'object' && config.service.name && config.service.name.length) {
+    import(`./services/${config.service.name}`)
+      .then(module => module.default(config, options))
+      .catch(err => console.error(`Can't load service module ${config.service.name}`, err))
   }
 
   return true
@@ -77,6 +84,10 @@ if (require.main === module) {
     .option("--locale <locale>", "Only extract the specified locale")
     .option("--overwrite", "Overwrite translations for source locale")
     .option("--clean", "Remove obsolete translations")
+    .option(
+      "--debounce <delay>",
+      "Debounces extraction for given amount of milliseconds"
+    )
     .option("--verbose", "Verbose output")
     .option(
       "--convert-from <format>",
@@ -148,39 +159,57 @@ if (require.main === module) {
     })
   }
 
+  const changedPaths = new Set<string>()
+  let debounceTimer: NodeJS.Timer
+  const dispatchExtract = (filePath?: string[]) => {
+    // Skip debouncing if not enabled
+    if (!program.debounce) return extract(filePath)
+
+    filePath?.forEach((path) => changedPaths.add(path))
+
+    // CLear the previous timer if there is any, and schedule the next
+    debounceTimer && clearTimeout(debounceTimer)
+    debounceTimer = setTimeout(() => {
+      const filePath = [...changedPaths]
+      changedPaths.clear()
+
+      extract(filePath)
+    }, program.debounce)
+  }
+
   // Check if Watch Mode is enabled
   if (program.watch) {
     console.info(chalk.bold("Initializing Watch Mode..."))
 
     const catalogs = getCatalogs(config)
-    let paths = [];
-    let ignored = [];
+    let paths = []
+    let ignored = []
 
     catalogs.forEach((catalog) => {
-      paths.push(...catalog.include);
-      ignored.push(...catalog.exclude);
+      paths.push(...catalog.include)
+      ignored.push(...catalog.exclude)
     })
 
     const watcher = chokidar.watch(paths, {
-      ignored: ['/(^|[\/\\])\../', ...ignored],
+      ignored: ["/(^|[/\\])../", ...ignored],
       persistent: true,
-    });
+    })
 
     const onReady = () => {
       console.info(chalk.green.bold("Watcher is ready!"))
       watcher
-      .on('add', (path) => extract([path]))
-      .on('change', (path) => extract([path]));
-    };
+        .on("add", (path) => dispatchExtract([path]))
+        .on("change", (path) => dispatchExtract([path]))
+    }
 
-    watcher.on('ready', () => onReady());
+    watcher.on("ready", () => onReady())
   } else if (program.args) {
     // this behaviour occurs when we extract files by his name
     // for ex: lingui extract src/app, this will extract only files included in src/app
-    const result = extract(program.args);
+    const result = extract(program.args)
     if (!result) process.exit(1)
   } else {
-    const result = extract();
+    const result = extract()
     if (!result) process.exit(1)
   }
 }
diff --git a/packages/cli/src/services/translationIO.ts b/packages/cli/src/services/translationIO.ts
new file mode 100644
index 00000000..07c391c3
--- /dev/null
+++ b/packages/cli/src/services/translationIO.ts
@@ -0,0 +1,293 @@
+import fs from "fs"
+import { dirname } from "path"
+import PO from "pofile"
+import https from "https"
+import glob from "glob"
+import { format as formatDate } from "date-fns"
+
+const getCreateHeaders = (language) => ({
+  "POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"),
+  "MIME-Version": "1.0",
+  "Content-Type": "text/plain; charset=utf-8",
+  "Content-Transfer-Encoding": "8bit",
+  "X-Generator": "@lingui/cli",
+  Language: language,
+})
+
+// Main sync method, call "Init" or "Sync" depending on the project context
+export default function syncProcess(config, options) {
+  if (config.format != 'po') {
+    console.error(`\n----------\nTranslation.io service is only compatible with the "po" format. Please update your Lingui configuration accordingly.\n----------`)
+    process.exit(1)
+  }
+
+  const successCallback = (project) => {
+    console.log(`\n----------\nProject successfully synchronized. Please use this URL to translate: ${project.url}\n----------`)
+  }
+
+  const failCallback = (errors) => {
+    console.error(`\n----------\nSynchronization with Translation.io failed: ${errors.join(', ')}\n----------`)
+  }
+
+  init(config, options, successCallback, (errors) => {
+    if (errors.length && errors[0] === 'This project has already been initialized.') {
+      sync(config, options, successCallback, failCallback)
+    }
+    else {
+      failCallback(errors)
+    }
+  })
+}
+
+// Initialize project with source and existing translations (only first time!)
+// Cf. https://translation.io/docs/create-library#initialization
+function init(config, options, successCallback, failCallback) {
+  const sourceLocale  = config.sourceLocale || 'en'
+  const targetLocales = config.locales.filter((value) => value != sourceLocale)
+  const paths         = poPathsPerLocale(config)
+
+  let segments = {}
+
+  targetLocales.forEach((targetLocale) => {
+    segments[targetLocale] = []
+  })
+
+  // Create segments from source locale PO items
+  paths[sourceLocale].forEach((path) => {
+    let raw = fs.readFileSync(path).toString()
+    let po = PO.parse(raw)
+
+    po.items.filter((item) => !item['obsolete']).forEach((item) => {
+      targetLocales.forEach((targetLocale) => {
+        let newSegment = createSegmentFromPoItem(item)
+
+        segments[targetLocale].push(newSegment)
+      })
+    })
+  })
+
+  // Add translations to segments from target locale PO items
+  targetLocales.forEach((targetLocale) => {
+    paths[targetLocale].forEach((path) => {
+      let raw = fs.readFileSync(path).toString()
+      let po = PO.parse(raw)
+
+      po.items.filter((item) => !item['obsolete']).forEach((item, index) => {
+        segments[targetLocale][index].target = item.msgstr[0]
+      })
+    })
+  })
+
+  let request = {
+    "client": "lingui",
+    "version": require('@lingui/core/package.json').version,
+    "source_language": sourceLocale,
+    "target_languages": targetLocales,
+    "segments": segments
+  }
+
+  postTio("init", request, config.service.apiKey, (response) => {
+    if (response.errors) {
+      failCallback(response.errors)
+    }
+    else {
+      saveSegmentsToTargetPos(config, paths, response.segments)
+      successCallback(response.project)
+    }
+  }, (error) => {
+    console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`)
+  })
+}
+
+// Send all source text from PO to Translation.io and create new PO based on received translations
+// Cf. https://translation.io/docs/create-library#synchronization
+function sync(config, options, successCallback, failCallback) {
+  const sourceLocale  = config.sourceLocale || 'en'
+  const targetLocales = config.locales.filter((value) => value != sourceLocale)
+  const paths         = poPathsPerLocale(config)
+
+  let segments = []
+
+  // Create segments with correct source
+  paths[sourceLocale].forEach((path) => {
+    let raw = fs.readFileSync(path).toString()
+    let po = PO.parse(raw)
+
+    po.items.filter((item) => !item['obsolete']).forEach((item) => {
+      let newSegment = createSegmentFromPoItem(item)
+
+      segments.push(newSegment)
+    })
+  })
+
+  let request = {
+    "client": "lingui",
+    "version": require('@lingui/core/package.json').version,
+    "source_language": sourceLocale,
+    "target_languages": targetLocales,
+    "segments": segments
+  }
+
+  // Sync and then remove unused segments (not present in the local application) from Translation.io
+  if (options.clean) {
+    request['purge'] = true
+  }
+
+  postTio("sync", request, config.service.apiKey, (response) => {
+    if (response.errors) {
+      failCallback(response.errors)
+    }
+    else {
+      saveSegmentsToTargetPos(config, paths, response.segments)
+      successCallback(response.project)
+    }
+  }, (error) => {
+    console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`)
+  })
+}
+
+function createSegmentFromPoItem(item) {
+  let itemHasId = item.msgid != item.msgstr[0] && item.msgstr[0].length
+
+  let segment = {
+    type: 'source',                                 // No way to edit text for source language (inside code), so not using "key" here
+    source: itemHasId ? item.msgstr[0] : item.msgid, // msgstr may be empty if --overwrite is used and no ID is used
+    context: '',
+    references: [],
+    comment: ''
+  }
+
+  if (itemHasId) {
+    segment.context = item.msgid
+  }
+
+  if (item.references.length) {
+    segment.references = item.references
+  }
+
+  if (item.extractedComments.length) {
+    segment.comment = item.extractedComments.join(' | ')
+  }
+
+  return segment
+}
+
+function createPoItemFromSegment(segment) {
+  let item = new PO.Item()
+
+  item.msgid             = segment.context ? segment.context : segment.source
+  item.msgstr            = [segment.target]
+  item.references        = (segment.references && segment.references.length) ? segment.references : []
+  item.extractedComments = segment.comment ? segment.comment.split(' | ') : []
+
+  return item
+}
+
+function saveSegmentsToTargetPos(config, paths, segmentsPerLocale) {
+  const NAME = "{name}"
+  const LOCALE = "{locale}"
+
+  Object.keys(segmentsPerLocale).forEach((targetLocale) => {
+    // Remove existing target POs and JS for this target locale
+    paths[targetLocale].forEach((path) => {
+      const jsPath  = path.replace(/\.po?$/, "") + ".js"
+      const dirPath = dirname(path)
+
+      // Remove PO, JS and empty dir
+      if (fs.existsSync(path)) { fs.unlinkSync(path) }
+      if (fs.existsSync(jsPath)) { fs.unlinkSync(jsPath) }
+      if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath) }
+    })
+
+    // Find target path (ignoring {name})
+    const localePath = "".concat(config.catalogs[0].path.replace(LOCALE, targetLocale).replace(NAME, ''), ".po")
+    const segments = segmentsPerLocale[targetLocale]
+
+    let po = new PO()
+    po.headers = getCreateHeaders(targetLocale)
+
+    let items = []
+
+    segments.forEach((segment) => {
+      let item = createPoItemFromSegment(segment)
+      items.push(item)
+    })
+
+    // Sort items by messageId
+    po.items = items.sort((a, b) => {
+      if (a.msgid < b.msgid) { return -1 }
+      if (a.msgid > b.msgid) { return 1 }
+      return 0
+    })
+
+    // Check that localePath directory exists and save PO file
+    fs.promises.mkdir(dirname(localePath), {recursive: true}).then(() => {
+      po.save(localePath, (err) => {
+        if (err) {
+          console.error('Error while saving target PO files:')
+          console.error(err)
+          process.exit(1)
+        }
+      })
+    })
+  })
+}
+
+function poPathsPerLocale(config) {
+  const NAME = "{name}"
+  const LOCALE = "{locale}"
+  const paths = []
+
+  config.locales.forEach((locale) => {
+    paths[locale] = []
+
+    config.catalogs.forEach((catalog) => {
+      const path = "".concat(catalog.path.replace(LOCALE, locale).replace(NAME, "*"), ".po")
+
+      // If {name} is present (replaced by *), list all the existing POs
+      if (path.includes('*')) {
+        paths[locale] = paths[locale].concat(glob.sync(path))
+      }
+      else {
+        paths[locale].push(path)
+      }
+    })
+  })
+
+  return paths
+}
+
+function postTio(action, request, apiKey, successCallback, failCallback) {
+  let jsonRequest = JSON.stringify(request)
+
+  let options = {
+    hostname: 'translation.io',
+    path: '/api/v1/segments/' + action + '.json?api_key=' + apiKey,
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    }
+  }
+
+  let req = https.request(options, (res) => {
+    res.setEncoding('utf8')
+
+    let body = ""
+
+    res.on('data', (chunk) => {
+      body = body.concat(chunk)
+    })
+
+    res.on('end', () => {
+      let response = JSON.parse(body)
+      successCallback(response)
+    })
+  })
+
+  req.on('error', (e) => {
+    failCallback(e)
+  })
+
+  req.write(jsonRequest)
+  req.end()
+}
diff --git a/packages/conf/index.d.ts b/packages/conf/index.d.ts
index fe47871e..227fb8db 100644
--- a/packages/conf/index.d.ts
+++ b/packages/conf/index.d.ts
@@ -1,6 +1,6 @@
 import type { GeneratorOptions } from "@babel/core";
 
-export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
+export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
 export type CatalogFormatOptions = {
     origins?: boolean;
     lineNumbers?: boolean;
@@ -23,6 +23,11 @@ export type DefaultLocaleObject = {
 
 export declare type FallbackLocales = LocaleObject | DefaultLocaleObject
 
+declare type CatalogService = {
+    name: string;
+    apiKey: string;
+}
+
 declare type ExtractorType = {
     match(filename: string): boolean;
     extract(filename: string, targetDir: string, options?: any): void;
@@ -46,6 +51,7 @@ export declare type LinguiConfig = {
     rootDir: string;
     runtimeConfigModule: [string, string?];
     sourceLocale: string;
+    service: CatalogService;
 };
 export declare const defaultConfig: LinguiConfig;
 export declare function getConfig({ cwd, configPath, skipValidation, }?: {
@@ -63,7 +69,7 @@ export declare const configValidation: {
         };
         compilerBabelOptions: GeneratorOptions;
         catalogs: CatalogConfig[];
-        compileNamespace: "es" | "ts" | "cjs" | string;
+        compileNamespace: "es" | "ts" | "cjs" | string;
         fallbackLocales: FallbackLocales;
         format: CatalogFormat;
         formatOptions: CatalogFormatOptions;
@@ -73,6 +79,7 @@ export declare const configValidation: {
         rootDir: string;
         runtimeConfigModule: [string, string?];
         sourceLocale: string;
+        service: CatalogService;
     };
     deprecatedConfig: {
         fallbackLocale: (config: LinguiConfig & DeprecatedFallbackLanguage) => string;
diff --git a/packages/conf/package.json b/packages/conf/package.json
index 7b83a584..d607fe58 100644
--- a/packages/conf/package.json
+++ b/packages/conf/package.json
@@ -1,6 +1,7 @@
 {
   "name": "@lingui/conf",
   "version": "3.10.4",
+  "sideEffects": false,
   "description": "Get lingui configuration from package.json",
   "keywords": [
     "lingui",
diff --git a/packages/conf/src/index.ts b/packages/conf/src/index.ts
index 6190ffc1..a8c89586 100644
--- a/packages/conf/src/index.ts
+++ b/packages/conf/src/index.ts
@@ -2,9 +2,8 @@ import type { GeneratorOptions } from "@babel/core"
 import path from "path"
 import fs from "fs"
 import chalk from "chalk"
-import { Loader, cosmiconfigSync } from "cosmiconfig"
+import { cosmiconfigSync } from "cosmiconfig"
 import { multipleValidOptions, validate } from "jest-validate"
-import get from "lodash.get"
 
 export type CatalogFormat = "lingui" | "minimal" | "po" | "csv"
 
@@ -33,6 +32,11 @@ export type FallbackLocales = LocaleObject | DefaultLocaleObject | false
 
 type ModuleSource = [string, string?]
 
+type CatalogService = {
+  name: string
+  apiKey: string
+}
+
 type ExtractorType = {
   match(filename: string): boolean
   extract(filename: string, targetDir: string, options?: any): void
@@ -44,7 +48,7 @@ export type LinguiConfig = {
   extractBabelOptions: Record<string, unknown>
   compilerBabelOptions: GeneratorOptions
   fallbackLocales?: FallbackLocales
-  extractors?: ExtractorType[]
+  extractors?: ExtractorType[] | string[]
   format: CatalogFormat
   formatOptions: CatalogFormatOptions
   locales: string[]
@@ -54,6 +58,7 @@ export type LinguiConfig = {
   rootDir: string
   runtimeConfigModule: ModuleSource | { [symbolName: string]: ModuleSource }
   sourceLocale: string
+  service: CatalogService
 }
 
 // Enforce posix path delimiters internally
@@ -81,6 +86,14 @@ export const defaultConfig: LinguiConfig = {
       minimal: true,
     }
   },
+  extractors: multipleValidOptions(
+    [],
+    ["babel"],
+    [{
+      match: (fileName: string) => false,
+      extract: (filename: string, targetDir: string, options?: any) => {}
+    } as ExtractorType]
+  ),
   extractBabelOptions: { plugins: [], presets: [] },
   fallbackLocales: {},
   format: "po",
@@ -91,6 +104,7 @@ export const defaultConfig: LinguiConfig = {
   rootDir: ".",
   runtimeConfigModule: ["@lingui/core", "i18n"],
   sourceLocale: "",
+  service: { name: "", apiKey: "" }
 }
 
 function configExists(path) {
diff --git a/packages/core/package.json b/packages/core/package.json
index 8cc30582..db2f13a0 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,7 @@
 {
   "name": "@lingui/core",
   "version": "3.10.4",
+  "sideEffects": false,
   "description": "I18n tools for javascript",
   "main": "index.js",
   "module": "esm/index.js",
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
index 2695d5e5..20ba1116 100644
--- a/packages/core/src/context.ts
+++ b/packages/core/src/context.ts
@@ -2,6 +2,8 @@ import { CompiledMessage, Locales } from "./i18n"
 import { date, number } from "./formats"
 import { isString, isFunction } from "./essentials"
 
+export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g;
+
 const defaultFormats = (
   locale,
   locales,
@@ -111,7 +113,7 @@ export function interpolate(
     }
 
     const result = formatMessage(translation)
-    if (isString(result) && /\\u[a-fA-F0-9]{4}/g.test(result)) return JSON.parse(`"${result.trim()}"`)
+    if (isString(result) && UNICODE_REGEX.test(result)) return JSON.parse(`"${result.trim()}"`)
     if (isString(result)) return result.trim()
     return result
   }
diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts
index d17a7a79..024fd776 100644
--- a/packages/core/src/i18n.ts
+++ b/packages/core/src/i18n.ts
@@ -1,4 +1,4 @@
-import { interpolate } from "./context"
+import { interpolate, UNICODE_REGEX } from "./context"
 import { isString, isFunction } from "./essentials"
 import { date, number } from "./formats"
 import * as icu from "./dev"
@@ -193,7 +193,7 @@ export class I18n extends EventEmitter<Events> {
 
 
     // hack for parsing unicode values inside a string to get parsed in react native environments
-    if (isString(translation) && /\\u[a-fA-F0-9]{4}/g.test(translation)) return JSON.parse(`"${translation}"`) as string;
+    if (isString(translation) && UNICODE_REGEX.test(translation)) return JSON.parse(`"${translation}"`) as string;
     if (isString(translation)) return translation
 
     return interpolate(
diff --git a/packages/detect-locale/package.json b/packages/detect-locale/package.json
index 56887c33..68b6e000 100644
--- a/packages/detect-locale/package.json
+++ b/packages/detect-locale/package.json
@@ -1,6 +1,7 @@
 {
   "name": "@lingui/detect-locale",
   "version": "3.10.4",
+  "sideEffects": false,
   "description": "@Lingui package to help you find the correct browser/server locale",
   "main": "index.js",
   "module": "esm/index.js",
diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts
index e1dbb2c9..3c5de09d 100644
--- a/packages/macro/src/macroJs.ts
+++ b/packages/macro/src/macroJs.ts
@@ -265,7 +265,9 @@ export default class MacroJs {
         quasis: R.map((text: babelTypes.TemplateElement) => {
           // Don't output tokens without text.
           // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars)
-          const value = /\\u[a-fA-F0-9]{4}/g.test(text.value.raw) ? text.value.cooked : text.value.raw
+          // This regex will detect if a string contains unicode chars, when they're we should interpolate them
+          // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly
+          const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test(text.value.raw) ? text.value.cooked : text.value.raw
           if (value === "") return null
 
           return {
diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts
index c7793549..4e4fcc31 100644
--- a/packages/macro/src/macroJsx.ts
+++ b/packages/macro/src/macroJsx.ts
@@ -230,8 +230,10 @@ export default class MacroJSX {
           // Don"t output tokens without text.
           R.evolve({
             quasis: R.map((text: babelTypes.TemplateElement) => {
-              // Don"t output tokens without text.
-              const value = /\\u[a-fA-F0-9]{4}/g.test(text.value.raw) ? text.value.cooked : text.value.raw
+              // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars)
+              // This regex will detect if a string contains unicode chars, when they're we should interpolate them
+              // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly
+              const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test(text.value.raw) ? text.value.cooked : text.value.raw
               if (value === "") return null
 
               return this.tokenizeText(this.clearBackslashes(value))
diff --git a/packages/react/package.json b/packages/react/package.json
index aa43e144..d20215bb 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,7 @@
 {
   "name": "@lingui/react",
   "version": "3.10.4",
+  "sideEffects": false,
   "description": "React components for translations",
   "main": "index.js",
   "module": "esm/index.js",
diff --git a/packages/remote-loader/package.json b/packages/remote-loader/package.json
index 023f0883..1e3788b1 100644
--- a/packages/remote-loader/package.json
+++ b/packages/remote-loader/package.json
@@ -1,6 +1,7 @@
 {
   "name": "@lingui/remote-loader",
   "version": "3.10.4",
+  "sideEffects": false,
   "description": "Load remotely your translations",
   "main": "index.js",
   "module": "esm/remote-loader.production.min.js",


Test patch:
diff --git a/packages/cli/src/api/__snapshots__/compile.test.ts.snap b/packages/cli/src/api/__snapshots__/compile.test.ts.snap
index 3b4f9617..c10e4941 100644
--- a/packages/cli/src/api/__snapshots__/compile.test.ts.snap
+++ b/packages/cli/src/api/__snapshots__/compile.test.ts.snap
@@ -24,6 +24,6 @@ exports[`createCompiledCatalog options.pseudoLocale should return catalog with p
 
 exports[`createCompiledCatalog options.pseudoLocale should return compiled catalog when pseudoLocale doesn't match current locale 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj"}};`;
 
-exports[`createCompiledCatalog options.strict should return message key as a fallback translation 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":"Missing"}};`;
+exports[`createCompiledCatalog options.strict should return message key as a fallback translation 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":"Missing","Select":[["id","select",{Gen:"Genesis","1John":"1 John",other:"____"}]]}};`;
 
-exports[`createCompiledCatalog options.strict should't return message key as a fallback in strict mode 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":""}};`;
+exports[`createCompiledCatalog options.strict should't return message key as a fallback in strict mode 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":"","Select":[["id","select",{Gen:"Genesis","1John":"1 John",other:"____"}]]}};`;
diff --git a/packages/cli/src/api/compile.test.ts b/packages/cli/src/api/compile.test.ts
index ffa02386..7e41102a 100644
--- a/packages/cli/src/api/compile.test.ts
+++ b/packages/cli/src/api/compile.test.ts
@@ -113,6 +113,7 @@ describe("createCompiledCatalog", () => {
         {
           Hello: "Ahoj",
           Missing: "",
+          Select: "{id, select, Gen {Genesis} 1John {1 John}  other {____}}"
         },
         {
           strict,
diff --git a/packages/conf/src/__snapshots__/index.test.ts.snap b/packages/conf/src/__snapshots__/index.test.ts.snap
index 3f88372b..08bba6b9 100644
--- a/packages/conf/src/__snapshots__/index.test.ts.snap
+++ b/packages/conf/src/__snapshots__/index.test.ts.snap
@@ -120,6 +120,18 @@ Object {
     plugins: Array [],
     presets: Array [],
   },
+  extractors: Array [
+    Array [],
+    Array [
+      babel,
+    ],
+    Array [
+      Object {
+        extract: [Function],
+        match: [Function],
+      },
+    ],
+  ],
   fallbackLocales: Object {
     en-gb: en,
   },
@@ -138,6 +150,10 @@ Object {
     @lingui/core,
     i18n,
   ],
+  service: Object {
+    apiKey: ,
+    name: ,
+  },
   sourceLocale: ,
 }
 `;
diff --git a/packages/macro/src/macroJs.test.ts b/packages/macro/src/macroJs.test.ts
index 406baba3..558c8519 100644
--- a/packages/macro/src/macroJs.test.ts
+++ b/packages/macro/src/macroJs.test.ts
@@ -80,7 +80,7 @@ describe("js macro", () => {
       ])
     })
 
-    it("message with unicode chars is interpreted by babel", () => {
+    it("message with unicode \\u chars is interpreted by babel", () => {
       const macro = createMacro()
       const exp = parseExpression('t`Message \\u0020`')
       const tokens = macro.tokenizeTemplateLiteral(exp)
@@ -92,6 +92,19 @@ describe("js macro", () => {
       ])
     })
 
+    it("message with unicode \\x chars is interpreted by babel", () => {
+      const macro = createMacro()
+      const exp = parseExpression('t`Bienvenue\\xA0!`')
+      const tokens = macro.tokenizeTemplateLiteral(exp)
+      expect(tokens).toEqual([
+        {
+          type: "text",
+          // Looks like an empty space, but it isn't 
+          value: 'Bienvenue !',
+        },
+      ])
+    })
+
     it("message with double scaped literals it's stripped", () => {
       const macro = createMacro()
       const exp = parseExpression('t\`Passing \\`${argSet}\\` is not supported.\`')