Repository: lingui/js-lingui (ts)

Issue:
ICU select does not generate valid Typescript for keys starting with numbers.
**Describe the bug**
ICU select does not generate valid Typescript for keys starting with numbers.

**To Reproduce**
Given the translation key:

```
msgid "book.name"
msgstr "{id, select, Gen {Genesis} 1John {1 John}  other {____}}"
```

it compiles to:

```
/*eslint-disable*/export const messages={"book.name":[["id","select",{Gen:"Genesis",1John:"1 John",other:"____"}]]};
```

**Expected behavior**
It should quote keys starting with a number, or IMHO every key for simplicity's sake.

```
/*eslint-disable*/export const messages={"book.name":[["id","select",{Gen:"Genesis","1John":"1 John",other:"____"}]]};
```

**Additional context**
Add any other context about the problem here.

- jsLingui version ``lingui --version``
3.10.2

- Babel version ``npm list @babel/core``
```
biblebox-web@20.3.0 /home/peleteiro/Projects/biblebox/web
├─┬ @babel/core@7.14.6
│ └─┬ @babel/helper-compilation-targets@7.14.5
│   └── @babel/core@7.14.6 deduped
├─┬ @babel/plugin-syntax-dynamic-import@7.8.3
│ └── @babel/core@7.14.6 deduped
├─┬ @emotion/react@11.4.0
│ └── @babel/core@7.14.6 deduped
├─┬ @emotion/styled@11.3.0
│ ├── @babel/core@7.14.6 deduped
│ └─┬ @emotion/babel-plugin@11.3.0
│   └── @babel/core@7.14.6 deduped
└─┬ @lingui/cli@3.10.2
  ├── @babel/core@7.14.6 deduped
  └─┬ @babel/plugin-syntax-jsx@7.12.13
    └── @babel/core@7.14.6 deduped
```

- Your Babel config (e.g. `.babelrc`) or framework you use (Create React App, Meteor, etc.)
```
{
  "presets": [
    [
      "next/babel",
      {
        "preset-react": {
          "runtime": "automatic",
          "importSource": "@emotion/react"
        }
      }
    ]
  ],
  "plugins": ["@emotion/babel-plugin", "@babel/plugin-syntax-dynamic-import", "macros"]
}
```

Pull request:
fix(): keys with leading number was generating bad object keys
Will close #1097

Patch:
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/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/conf/index.d.ts b/packages/conf/index.d.ts
index fe47871e..f1eced4b 100644
--- a/packages/conf/index.d.ts
+++ b/packages/conf/index.d.ts
@@ -1,5 +1,6 @@
 import type { GeneratorOptions } from "@babel/core";
 
+export const UNICODE_REGEX: RegExp
 export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
 export type CatalogFormatOptions = {
     origins?: boolean;
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..b6787306 100644
--- a/packages/conf/src/index.ts
+++ b/packages/conf/src/index.ts
@@ -2,10 +2,11 @@ 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"
-
+// 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
+export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g;
 export type CatalogFormat = "lingui" | "minimal" | "po" | "csv"
 
 export type CatalogFormatOptions = {
@@ -44,7 +45,7 @@ export type LinguiConfig = {
   extractBabelOptions: Record<string, unknown>
   compilerBabelOptions: GeneratorOptions
   fallbackLocales?: FallbackLocales
-  extractors?: ExtractorType[]
+  extractors?: ExtractorType[] | string[]
   format: CatalogFormat
   formatOptions: CatalogFormatOptions
   locales: string[]
@@ -81,6 +82,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",
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..e6978607 100644
--- a/packages/macro/src/macroJs.ts
+++ b/packages/macro/src/macroJs.ts
@@ -1,6 +1,7 @@
 import * as R from "ramda"
 import * as babelTypes from "@babel/types"
 import { NodePath } from "@babel/traverse"
+import { UNICODE_REGEX } from "@lingui/conf"
 
 import ICUMessageFormat from "./icu"
 import { zip, makeCounter } from "./utils"
@@ -265,7 +266,7 @@ 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
+          const value = UNICODE_REGEX.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..f0e3cde5 100644
--- a/packages/macro/src/macroJsx.ts
+++ b/packages/macro/src/macroJsx.ts
@@ -1,6 +1,7 @@
 import * as R from "ramda"
 import * as babelTypes from "@babel/types"
 import { NodePath } from "@babel/traverse"
+import { UNICODE_REGEX } from "@lingui/conf"
 
 import ICUMessageFormat from "./icu"
 import { zip, makeCounter } from "./utils"
@@ -231,7 +232,7 @@ export default class MacroJSX {
           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
+              const value = UNICODE_REGEX.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..27bdca3f 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,
   },
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.\`')