Repository: lingui/js-lingui (ts)

Issue:
Detect locale
User's locale should be detected automatically if possible, but also allow customization. The approach should work both on server and in browser and it should follow guidelines for [multilingual sites](https://support.google.com/webmasters/answer/182192?hl=en&ref_topic=2370587)

## Proposal

Websites which serve different content for each locale (e.g. Wikipedia) will always use just URL to detect the current locale.

Locale-adaptive apps (i.e. same content, but UI is internationalized, like GitHub, Twitter, Facebook) should detect locale from following sources:

On server, detect locale from:

1. URL (subdomain `en.example.com`, path `example.com/en/` or search param `example.com?l=en`). This is useful even for locale-adaptive apps for search engines, i.e. user's will always use `example.com` and customize locale through user's profile or cookies, but search engine will be advised to follow `example.com/?l=en` to crawl site in specific locale.
2. Cookie (for user's overrides)
3. [HTTP Accept-Language header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)

In browser, detect locale from:

1. `<html lang>` attribute (if set by server). 

This should be the only option for server-side rendered pages. For client-side rendered pages, there are other options:

2. URL (same motivation as above)
3. Cookie (for user's overrides)
4. [navigator.language](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorLanguage/language) 

## Implementation

Each _detector_ `<please insert better name>` should be just a plain function returning a locale or null (if not found):

```
type Detector<Config extends {}> = (config: Config) => string | null

// just an example with pseudocode
function detectFromCookie({ name, expiration }) {
  if (typeof window !== "undefined") {
    // server side code
    if (has cookie) {
      return req.cookie[name]
    }
  } else {
    // client-side code
  }
}
```

Then we can chain them together to get the first match:

```
const locale = detect(
  detectFromURL(),
  detectFromCookie({ cookieName: "locale" }),
  detectFromRequest()
)
```

## Resources

- [Guide - Detecting active locale in LinguiJS] - I've started writing draft of guide, it's available in [`nxt/detect-locale`](https://raw.githubusercontent.com/lingui/js-lingui/6f58868e7dbb50fddb444c251bf963f5d2b9ecab/docs/guides/active-locale.rst) branch. Feel free to use it or throw it away, I just needed to figure out a possible approach.
- [fluent-langneg](https://github.com/projectfluent/fluent.js/tree/master/fluent-langneg) - once we have a list of requested locales, we might compare it with list of available locales to get the best match. Probably only useful when detecting locale from `navigator.language` or `Accent-Language` header.
- [i18next/language-detector](https://github.com/i18next/i18next-browser-languageDetector/blob/master/src/index.js) includes several strategies to detect locale. The implementation seems to be tighly coupled to i18next, but it could definitely serve as an inspiration.

## Checklist

- [ ] Create a new package `@lingui/detect-locale` or find existing solution in JS ecosystem.
- [ ] Document how to detect locale in different kind of apps (static vs locale-adaptive, SSR vs CSR)

Pull request:
feat: introduced some initial client detectors
Will close #577


### Tasks

- [X] Detector fromUrl(parameter: string, location?) - `returns from the query-string params the correct value`
- [X] Detector fromCookie(key: string) - `returns from the cookie the stored value`
- [X] Detector fromStorage(key: string, { useSessionStorage: true }),  - ` recovers from localstorage or session storage if the options passed the corresponding value`
- [X] Detector fromNavigator() - `detects the locale via navigator.language || navigator.userLanguage (ie 11 comaptibility)`

### Pending
- [ ] Some server side detectors... (to be discussed since next-js is already working on it, probably cookies and headers will be cool)
- [x] Html tags (like html=en)
- [x] Path and subdomain parameters 
- [x] Write documentation

All is fully tested, inclusive the utils that are being used like cookie-getter and the query-string parser 

### Current api

```js
import { detect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"

// can be a function with custom logic or just a string, `detect` method will handle it
const DEFAULT_FALLBACK = () => "en"

detect(
  fromUrl("lang"),
  fromStorage("lang"),
  fromNavigator(),
  DEFAULT_FALLBACK
)
```

Patch:
diff --git a/docs/index.rst b/docs/index.rst
index 2885aced..e2f70810 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -106,6 +106,7 @@ Documentation contents
    Macros <ref/macro>
    CLI <ref/cli>
    Configuration <ref/conf>
+   Locale Detection <ref/locale-detector>
    Webpack Loader <ref/loader>
    Catalog Formats <ref/catalog-formats>
    ICU MessageFormat <ref/message-format>
diff --git a/docs/ref/locale-detector.rst b/docs/ref/locale-detector.rst
new file mode 100644
index 00000000..87904703
--- /dev/null
+++ b/docs/ref/locale-detector.rst
@@ -0,0 +1,74 @@
+*******************************************************
+API Reference - Locale Detection (@lingui/detect-locale)
+*******************************************************
+
+``@lingui/detect-locale`` is little package ``just (922 B Gzip)`` with some helper functions that will help you detect the locale of the user:
+
+Installation
+============
+
+Install ``@lingui/detect-locale`` as a dependency:
+
+.. code-block:: sh
+
+   npm install --save @lingui/detect-locale
+
+   # Or using yarn
+   # yarn add @lingui/detect-locale
+
+Usage
+=====
+
+``@lingui/detect-locale:`` exports multiple methods:
+  - ``detect`` - `Will return the first occurence of detectors`
+  - ``multipleDetect`` - `Will return an array with all the locales detected by each detector`
+
+and some helpers:
+  - ``fromCookie(key: string)`` - `Accepts a key as param will recover from navigator cookies the value`
+  - ``fromHtmlTag(tag: string)`` - `Will find on HtmlDocument the attribute passed in params (normally it's used lang or xml:lang)`
+  - ``fromNavigator()`` - `Recovers the navigator language, it's also compatible with old browsers like IE11`
+  - ``fromPath(localePathIndex: number)`` - `Splits the location.pathname in an array so you have to specify the index of the array where's locale is set`
+  - ``fromStorage(key: string, { useSessionStorage: boolean }`` - `Will search on localStorage by default the item that has that key, if **useSessionStorage** is passed, will search on sessionStorage`
+  - ``fromSubdomain(localeSubdomainIndex: number)`` - `Like fromPath, splits the location.href on segments you must specify the index of that segment`
+  - ``fromUrl(parameter: string)`` - `Uses a query-string parser to recover the correct parameter`
+
+Practically all detectors accepts a custom document, location, or window object as param, it's usefull when testing or using some server-side strategy
+
+Usage with ``detect``
+====
+
+.. code-block:: jsx
+
+  import { detect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"
+
+  // can be a function with custom logic or just a string, `detect` method will handle it
+  const DEFAULT_FALLBACK = () => "en"
+
+  const result = detect(
+    fromUrl("lang"),
+    fromStorage("lang"),
+    fromNavigator(),
+    DEFAULT_FALLBACK
+  )
+
+  console.log(result) // "en"
+
+
+Usage with ``multipleDetect``
+====
+
+.. code-block:: jsx
+
+  import { multipleDetect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"
+
+  // can be a function with custom logic or just a string, `detect` method will handle it
+  const DEFAULT_FALLBACK = () => "en"
+
+  const result = multipleDetect(
+    fromUrl("lang"),
+    fromStorage("lang"),
+    fromNavigator(),
+    DEFAULT_FALLBACK
+  )
+
+  console.log(result) // ["en", "es"]
\ No newline at end of file
diff --git a/docs/releases/migration-3.rst b/docs/releases/migration-3.rst
index ec673f1c..cf40a188 100644
--- a/docs/releases/migration-3.rst
+++ b/docs/releases/migration-3.rst
@@ -160,8 +160,8 @@ for a single locale or multiple catalogs at once.
 - command ``lingui init`` was removed
 - command ``lingui add-locale`` was removed
 
-Whitespace
-----------
+Whitespace and HTML entities
+----------------------------
 
 Whitespace handling in plugins had few bugs. By fixing them, there might be few
 backward incompatible changes. It's advised to run :cli:`extract` and inspect
@@ -193,6 +193,15 @@ changes in catalogs (if any).
 
       // Becomes: 1. Item\n2. Item
 
+3. Keep HTML entities. HTML entities are replaced with characters in Babel. They are now
+   kept in message catalogs and replaced only when rendered:
+
+   .. code-block:: jsx
+
+      <Trans>&quot;Hello&quot;</Trans>
+
+      // Becomes: &quot;Hello&quot;
+
 Plugins/Presets
 ---------------
 
diff --git a/examples/next-js/yarn.lock b/examples/next-js/yarn.lock
index 8479a0dd..16a97463 100644
--- a/examples/next-js/yarn.lock
+++ b/examples/next-js/yarn.lock
@@ -1096,10 +1096,10 @@
   resolved "http://0.0.0.0:4873/@hapi%2fhoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6"
   integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==
 
-"@jest/types@^26.6.1":
-  version "26.6.1"
-  resolved "http://0.0.0.0:4873/@jest%2ftypes/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5"
-  integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg==
+"@jest/types@^26.5.2":
+  version "26.5.2"
+  resolved "http://0.0.0.0:4873/@jest%2ftypes/-/types-26.5.2.tgz#44c24f30c8ee6c7f492ead9ec3f3c62a5289756d"
+  integrity sha512-QDs5d0gYiyetI8q+2xWdkixVQMklReZr4ltw7GFDtb4fuJIBCE6mzj2LnitGqCuAlLap6wPyb8fpoHgwZz5fdg==
   dependencies:
     "@types/istanbul-lib-coverage" "^2.0.0"
     "@types/istanbul-reports" "^3.0.0"
@@ -1107,28 +1107,28 @@
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
 
-"@lingui/babel-plugin-extract-messages@3.0.0-22":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2fbabel-plugin-extract-messages/-/babel-plugin-extract-messages-3.0.0-22.tgz#63d1da41fa105bd5b245aa4142a2e87712d95a73"
-  integrity sha512-9OY+vCw0ASJ5zaa4ayUTpRSODBmhs8R8hRx7p45kvshE8gKhyFCIgQsE9gCLuWOh2h+2TybertxBFpTiSy0HLw==
+"@lingui/babel-plugin-extract-messages@3.0.0-19":
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2fbabel-plugin-extract-messages/-/babel-plugin-extract-messages-3.0.0-19.tgz#c147fd643314016684a5a99e92a1f9642c5713b2"
+  integrity sha512-9wh4pPy7oDkuIWr+8vIdas6aMcFaTsuX+QPs9Hj+qHpPbCVPbVvdeEpg3NrBSZIP/DYeeZtduBDTDBppsevK7w==
   dependencies:
     "@babel/generator" "^7.11.6"
     "@babel/runtime" "^7.11.2"
-    "@lingui/conf" "3.0.0-22"
+    "@lingui/conf" "3.0.0-19"
     mkdirp "^1.0.4"
 
-"@lingui/cli@3.0.0-22", "@lingui/cli@^3.0.0-15":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2fcli/-/cli-3.0.0-22.tgz#42f0f1e7f7a3d973dcf8ebcafa5b70073cd42d8a"
-  integrity sha512-EJBNnTupMxCEXdwHkMRprpRhmDN5gv3MRGz9NKPfbb8nWiU/YEo/RpZ+3gX9Y1BrHaHgBNQlT0TN/DCGauAxiA==
+"@lingui/cli@3.0.0-19", "@lingui/cli@^3.0.0-15":
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2fcli/-/cli-3.0.0-19.tgz#f3860a08280597c9a1823542a7298ba3813645a5"
+  integrity sha512-A6kp+e3wad71P2kqMGa/VLbfd9hX00C+GzKbvuyJlLtDWQUzUAx/WErYozjnYj3S4WV0tfxQNhEB/Hm9HkIxJw==
   dependencies:
     "@babel/generator" "^7.11.6"
     "@babel/parser" "^7.11.5"
     "@babel/plugin-syntax-jsx" "^7.10.4"
     "@babel/runtime" "^7.11.2"
     "@babel/types" "^7.11.5"
-    "@lingui/babel-plugin-extract-messages" "3.0.0-22"
-    "@lingui/conf" "3.0.0-22"
+    "@lingui/babel-plugin-extract-messages" "3.0.0-19"
+    "@lingui/conf" "3.0.0-19"
     babel-plugin-macros "^2.8.0"
     bcp-47 "^1.0.7"
     chalk "^4.1.0"
@@ -1150,52 +1150,52 @@
     pseudolocale "^1.1.0"
     ramda "^0.27.1"
 
-"@lingui/conf@3.0.0-22":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2fconf/-/conf-3.0.0-22.tgz#346ffd444c493255853fd7c4ece9ab22e71219f4"
-  integrity sha512-bOWXN2bNNDaY1poW5NY7WPfF2otiC36CVYjbHH66Ua2yNGYMV+N8LrYQ4liFUgEtq98fXU8m6z/TldzdBSh3Pw==
+"@lingui/conf@3.0.0-19":
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2fconf/-/conf-3.0.0-19.tgz#0f97eed746102b819ee227bdbbd858f9b9d56a1f"
+  integrity sha512-PcLs9XXPEsGB05Q4ktUs2QvGHVJNExcKmeY889tXYFzVg1pP0aXVDVTcE2QwOi+8/EAxTzeBk7sRhAFVV7NhQQ==
   dependencies:
     "@babel/runtime" "^7.11.2"
     chalk "^4.1.0"
     cosmiconfig "^7.0.0"
     jest-validate "^26.5.2"
 
-"@lingui/core@3.0.0-22", "@lingui/core@^3.0.0-14":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2fcore/-/core-3.0.0-22.tgz#ce0bea56416e0887383e143d850fe6f02c207d48"
-  integrity sha512-tH4rBV1ue5Y1O0R4Uzib4CD9mp3ZO2KjmK1OH5Txd0wGcSxddJSQlgOP1+zcbzgg0rM21sT0Okew/dgvTyzuZw==
+"@lingui/core@3.0.0-19", "@lingui/core@^3.0.0-14":
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2fcore/-/core-3.0.0-19.tgz#786b800f77e3bbd177957a86768e65a52f1f7100"
+  integrity sha512-Lb00c0cWzKRiQ8Br97MtxqtBSgtiYdDZD930onwrlgP5RVVJ8ELXElq8w5i3Fv298Tv83kZTF2vyR/O8Qvglmw==
   dependencies:
     "@babel/runtime" "^7.11.2"
     make-plural "^6.2.2"
     messageformat-parser "^4.1.3"
 
 "@lingui/loader@^3.0.0-15":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2floader/-/loader-3.0.0-22.tgz#11c8e6b763e0f631cc8df568fce0cc55d4b55116"
-  integrity sha512-sJv3BEP3LswUQJnYbluWGksl5XvNESyj+ByAQNB5LoLEJICNImaluFptASuX0P0JM8JSqXMfd6Q6lq4ckoL/mA==
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2floader/-/loader-3.0.0-19.tgz#ca545e9b7724ac00906b5ffa3a14e3e4379ab760"
+  integrity sha512-WslWv3fwU06aMTwApFk1Y4J2olfgzhLJlfB2kwIGaIW2AbJmpxr3gas88tkfj4wZtOEkRIVwex38tH8GDgr3aQ==
   dependencies:
     "@babel/runtime" "^7.11.2"
-    "@lingui/cli" "3.0.0-22"
-    "@lingui/conf" "3.0.0-22"
+    "@lingui/cli" "3.0.0-19"
+    "@lingui/conf" "3.0.0-19"
     loader-utils "^2.0.0"
     ramda "^0.27.1"
 
 "@lingui/macro@^3.0.0-15":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2fmacro/-/macro-3.0.0-22.tgz#bd7ec830f23e80864e07560f036a86f57fd9144d"
-  integrity sha512-l68pDM161E5GKblZbXe3IxfStM9IfBVWtnbZISOJ5sh6mjIbQeotS9CQsoQZqsuB98UCz9wJE/ZY7aI3nlkN8w==
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2fmacro/-/macro-3.0.0-19.tgz#a9d787be79596a05ed8a0d952db90489b7f0478c"
+  integrity sha512-i6QRsSWcDFTKpJEYvzPgotEIMMva+0s99C5aI5rggEmc6DITb3Tc1v/E2uRzWgWtpvJ5Ajz2OZ7YmWYw4WTi9A==
   dependencies:
     "@babel/runtime" "^7.11.2"
-    "@lingui/conf" "3.0.0-22"
+    "@lingui/conf" "3.0.0-19"
     ramda "^0.27.1"
 
 "@lingui/react@^3.0.0-14":
-  version "3.0.0-22"
-  resolved "http://0.0.0.0:4873/@lingui%2freact/-/react-3.0.0-22.tgz#d581094f23d59dc6d3d9b5ee13c362c47816c29f"
-  integrity sha512-S0Dzi6AyfAUHRHhQVDi1zLq7bnr56RBjyqpEpq2HnGMer5nBfSzIty63/YcwGN/ZobviCLrJqhw5N674aq5pbQ==
+  version "3.0.0-19"
+  resolved "http://0.0.0.0:4873/@lingui%2freact/-/react-3.0.0-19.tgz#f7cdb37374756cc288e4e4d8a940d420b0bdc3df"
+  integrity sha512-lkaM4Y+lA5amBB7JmSvM0cEha0VCPWsxk8OGOs7xt4mFbIzew1XArLsLDhh1eyK2gTt31NAa8ZabtiVM09xt2w==
   dependencies:
     "@babel/runtime" "^7.11.2"
-    "@lingui/core" "3.0.0-22"
+    "@lingui/core" "3.0.0-19"
 
 "@next/env@9.5.5":
   version "9.5.5"
@@ -1260,9 +1260,9 @@
   integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
 
 "@types/node@*":
-  version "14.14.3"
-  resolved "http://0.0.0.0:4873/@types%2fnode/-/node-14.14.3.tgz#e1c09064121f894baaad2bd9f12ce4a41bffb274"
-  integrity sha512-33/L34xS7HVUx23e0wOT2V1qPF1IrHgQccdJVm9uXGTB9vFBrrzBtkQymT8VskeKOxjz55MSqMv0xuLq+u98WQ==
+  version "14.11.10"
+  resolved "http://0.0.0.0:4873/@types%2fnode/-/node-14.11.10.tgz#8c102aba13bf5253f35146affbf8b26275069bef"
+  integrity sha512-yV1nWZPlMFpoXyoknm4S56y2nlTAuFYaJuQtYRAOU7xA/FJ9RY0Xm7QOkaYMMmr8ESdHIuUb6oQgR/0+2NqlyA==
 
 "@types/parse-json@^4.0.0":
   version "4.0.0"
@@ -2141,9 +2141,9 @@ colors@1.0.3:
   integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
 
 commander@*, commander@^6.1.0:
-  version "6.2.0"
-  resolved "http://0.0.0.0:4873/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75"
-  integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==
+  version "6.1.0"
+  resolved "http://0.0.0.0:4873/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc"
+  integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==
 
 commander@^2.20.0:
   version "2.20.3"
@@ -3441,16 +3441,16 @@ jest-get-type@^26.3.0:
   integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==
 
 jest-validate@^26.5.2:
-  version "26.6.1"
-  resolved "http://0.0.0.0:4873/jest-validate/-/jest-validate-26.6.1.tgz#28730eb8570d60968d9d06f1a8c94d922167bd2a"
-  integrity sha512-BEFpGbylKocnNPZULcnk+TGaz1oFZQH/wcaXlaXABbu0zBwkOGczuWgdLucUouuQqn7VadHZZeTvo8VSFDLMOA==
+  version "26.5.3"
+  resolved "http://0.0.0.0:4873/jest-validate/-/jest-validate-26.5.3.tgz#eefd5a5c87059550548c5ad8d6589746c66929e3"
+  integrity sha512-LX07qKeAtY+lsU0o3IvfDdN5KH9OulEGOMN1sFo6PnEf5/qjS1LZIwNk9blcBeW94pQUI9dLN9FlDYDWI5tyaA==
   dependencies:
-    "@jest/types" "^26.6.1"
+    "@jest/types" "^26.5.2"
     camelcase "^6.0.0"
     chalk "^4.0.0"
     jest-get-type "^26.3.0"
     leven "^3.1.0"
-    pretty-format "^26.6.1"
+    pretty-format "^26.5.2"
 
 jest-worker@24.9.0:
   version "24.9.0"
@@ -4412,15 +4412,15 @@ postcss@^7.0.14, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.
     source-map "^0.6.1"
     supports-color "^6.1.0"
 
-pretty-format@^26.6.1:
-  version "26.6.1"
-  resolved "http://0.0.0.0:4873/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8"
-  integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA==
+pretty-format@^26.5.2:
+  version "26.5.2"
+  resolved "http://0.0.0.0:4873/pretty-format/-/pretty-format-26.5.2.tgz#5d896acfdaa09210683d34b6dc0e6e21423cd3e1"
+  integrity sha512-VizyV669eqESlkOikKJI8Ryxl/kPpbdLwNdPs2GrbQs18MpySB5S0Yo0N7zkg2xTRiFq4CFw8ct5Vg4a0xP0og==
   dependencies:
-    "@jest/types" "^26.6.1"
+    "@jest/types" "^26.5.2"
     ansi-regex "^5.0.0"
     ansi-styles "^4.0.0"
-    react-is "^17.0.1"
+    react-is "^16.12.0"
 
 process-nextick-args@~2.0.0:
   version "2.0.1"
@@ -4550,16 +4550,11 @@ react-dom@16.13.1:
     prop-types "^15.6.2"
     scheduler "^0.19.1"
 
-react-is@16.13.1, react-is@^16.8.1:
+react-is@16.13.1, react-is@^16.12.0, react-is@^16.8.1:
   version "16.13.1"
   resolved "http://0.0.0.0:4873/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
 
-react-is@^17.0.1:
-  version "17.0.1"
-  resolved "http://0.0.0.0:4873/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
-  integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
-
 react-refresh@0.8.3:
   version "0.8.3"
   resolved "http://0.0.0.0:4873/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
diff --git a/packages/detect-locale/index.js b/packages/detect-locale/index.js
new file mode 100644
index 00000000..46e72b16
--- /dev/null
+++ b/packages/detect-locale/index.js
@@ -0,0 +1,1 @@
+export * from "./src"
diff --git a/packages/detect-locale/package.json b/packages/detect-locale/package.json
new file mode 100644
index 00000000..2b6c4d50
--- /dev/null
+++ b/packages/detect-locale/package.json
@@ -0,0 +1,46 @@
+{
+  "name": "@lingui/detect-locale",
+  "version": "0.0.0-managed-by-release-script",
+  "description": "@Lingui package to help you find the correct browser/server locale",
+  "main": "index.js",
+  "types": "cjs/index.d.ts",
+  "license": "MIT",
+  "keywords": [
+    "i18n",
+    "i10n",
+    "i9n",
+    "internationalization",
+    "localization",
+    "translation"
+  ],
+  "author": {
+    "name": "Sergio Moreno",
+    "email": "sergiomorenoalbert@gmail.com"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/lingui/js-lingui.git"
+  },
+  "bugs": {
+    "url": "https://github.com/lingui/js-lingui/issues"
+  },
+  "engines": {
+    "node": ">=10.0.0"
+  },
+  "exports": {
+    "require": "./index.js",
+    "import": "./esm/index.js"
+  },
+  "files": [
+    "LICENSE",
+    "README.md",
+    "index.js",
+    "cjs/",
+    "esm/"
+  ],
+  "private": false,
+  "dependencies": {},
+  "devDependencies": {
+    "jsdom": "^16.4.0"
+  }
+}
diff --git a/packages/detect-locale/src/detectors/fromCookie.ts b/packages/detect-locale/src/detectors/fromCookie.ts
new file mode 100644
index 00000000..b8bbd96b
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromCookie.ts
@@ -0,0 +1,8 @@
+import { LocaleString } from ".."
+import { getCookie } from "../utils/cookie-getter"
+
+export default function detectFromCookie(
+  key: string
+): LocaleString {
+  return getCookie(key)
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromHtmlTag.ts b/packages/detect-locale/src/detectors/fromHtmlTag.ts
new file mode 100644
index 00000000..a193ed34
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromHtmlTag.ts
@@ -0,0 +1,12 @@
+import { LocaleString } from ".."
+
+export default function detectHtmlTag(
+  htmlTagIdentifier: string,
+  document: Partial<Document> = globalThis.document
+): LocaleString {
+  if (htmlTagIdentifier) {
+    return document.documentElement.getAttribute(htmlTagIdentifier)
+  }
+
+  return null
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromNavigator.ts b/packages/detect-locale/src/detectors/fromNavigator.ts
new file mode 100644
index 00000000..f8843e9a
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromNavigator.ts
@@ -0,0 +1,13 @@
+import { LocaleString } from ".."
+
+type IE11NavigatorLanguage = {
+  userLanguage?: string
+}
+
+export default function detectFromNavigator(
+  navigator: Partial<Navigator & IE11NavigatorLanguage> = globalThis.navigator
+): LocaleString {
+
+  const result: LocaleString = navigator.language || navigator.userLanguage
+  return result
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromPath.ts b/packages/detect-locale/src/detectors/fromPath.ts
new file mode 100644
index 00000000..3529404c
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromPath.ts
@@ -0,0 +1,13 @@
+import { LocaleString } from "../"
+
+export default function detectFromPath(
+  localePathIndex: number,
+  location: Partial<Location> = globalThis.location,
+): LocaleString {
+  const locale: RegExpMatchArray = location.pathname.match(/\/([a-zA-Z-]*)/g)
+  if (Array.isArray(locale)) {
+    return locale[localePathIndex].replace("/", "")
+  }
+
+  return null
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromStorage.ts b/packages/detect-locale/src/detectors/fromStorage.ts
new file mode 100644
index 00000000..bc7af1bf
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromStorage.ts
@@ -0,0 +1,12 @@
+import { LocaleString } from ".."
+
+export default function detectFromStorage(
+  key: string,
+  options: { useSessionStorage: boolean } = { useSessionStorage: false }
+): LocaleString {
+  if (options.useSessionStorage) {
+    return globalThis.sessionStorage.getItem(key);
+  }
+
+  return globalThis.localStorage.getItem(key);
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromSubdomain.ts b/packages/detect-locale/src/detectors/fromSubdomain.ts
new file mode 100644
index 00000000..909e3b9f
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromSubdomain.ts
@@ -0,0 +1,16 @@
+import { LocaleString } from "../"
+
+export default function detectFromSubdomain(
+  localeSubdomainIndex: number,
+  location: Partial<Location> = globalThis.location,
+): LocaleString {
+  const locale: RegExpMatchArray = location.href.match(/(?:http[s]*\:\/\/)*(.*?)\.(?=[^\/]*\..{2,5})/gi)
+  if (Array.isArray(locale)) {
+    return locale[localeSubdomainIndex]
+      .replace('http://', '')
+      .replace('https://', '')
+      .replace('.', '')
+  }
+
+  return null
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromUrl.ts b/packages/detect-locale/src/detectors/fromUrl.ts
new file mode 100644
index 00000000..20b666c0
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromUrl.ts
@@ -0,0 +1,12 @@
+import { parse } from "../utils/query-string";
+import { LocaleString } from "..";
+
+export default function detectFromUrl(
+  parameter: string,
+  location: Partial<Location> = globalThis.location
+): LocaleString {
+  if (!parameter) throw new Error("fromUrl parameter is required");
+
+  const result: LocaleString = parse(location.search)[parameter] || null
+  return result
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/index.ts b/packages/detect-locale/src/index.ts
new file mode 100644
index 00000000..1ff668b0
--- /dev/null
+++ b/packages/detect-locale/src/index.ts
@@ -0,0 +1,42 @@
+import "./utils/globalThis.polyfill.js"
+import fromCookie from "./detectors/fromCookie"
+import fromPath from "./detectors/fromPath"
+import fromStorage from "./detectors/fromStorage"
+import fromNavigator from "./detectors/fromNavigator"
+import fromSubdomain from "./detectors/fromSubdomain"
+import fromHtmlTag from "./detectors/fromHtmlTag"
+import fromUrl from "./detectors/fromUrl"
+
+export type LocaleString = string
+export type DetectParamsFunctions = string
+
+function detect(...args): LocaleString | null {
+  for (let i = 0; i < args.length; i++) {
+    const res: LocaleString = typeof args[i] === "function" ? args[i]() : args[i]
+    if (res) return res
+  }
+
+  return null
+}
+
+function multipleDetect(...args): LocaleString[] {
+  const locales = []
+  for (let i = 0; i < args.length; i++) {
+    const res: LocaleString = typeof args[i] === "function" ? args[i]() : args[i]
+    if (res) locales.push(res)
+  }
+
+  return locales
+}
+
+export {
+  detect,
+  multipleDetect,
+  fromCookie,
+  fromHtmlTag,
+  fromNavigator,
+  fromPath,
+  fromStorage,
+  fromSubdomain,
+  fromUrl,
+}
diff --git a/packages/detect-locale/src/utils/cookie-getter.ts b/packages/detect-locale/src/utils/cookie-getter.ts
new file mode 100644
index 00000000..3bc855cf
--- /dev/null
+++ b/packages/detect-locale/src/utils/cookie-getter.ts
@@ -0,0 +1,29 @@
+export function getCookie(key: string): string {
+  if (!key) {
+    return;
+  }
+
+  // To prevent the for loop in the first place assign an empty array
+  // in case there are no cookies at all.
+  const cookies = globalThis.document.cookie ? globalThis.document.cookie.split('; ') : []
+  const jar = {}
+  for (let i = 0; i < cookies.length; i++) {
+    const parts = cookies[i].split('=')
+    let value = parts.slice(1).join('=')
+
+    if (value[0] === '"') {
+      value = value.slice(1, -1)
+    }
+
+    try {
+      const foundKey = parts[0].replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
+      jar[foundKey] = value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
+
+      if (key === foundKey) {
+        break
+      }
+    } catch (e) {}
+  }
+
+  return key ? jar[key] : jar
+}
\ No newline at end of file
diff --git a/packages/detect-locale/src/utils/globalThis.polyfill.js b/packages/detect-locale/src/utils/globalThis.polyfill.js
new file mode 100644
index 00000000..bb88e5f7
--- /dev/null
+++ b/packages/detect-locale/src/utils/globalThis.polyfill.js
@@ -0,0 +1,18 @@
+// https://mathiasbynens.be/notes/globalthis
+// We polyfill the globalThis instance for node10 environments
+// since globalThis only exists since node12+
+(function (Object) {
+  typeof globalThis !== 'object' && (
+    this ?
+      get() :
+      (Object.defineProperty(Object.prototype, '_T_', {
+        configurable: true,
+        get: get
+      }), _T_)
+  );
+  function get() {
+    this.globalThis = this;
+    delete Object.prototype._T_;
+  }
+}(Object));
+export default globalThis;
\ No newline at end of file
diff --git a/packages/detect-locale/src/utils/query-string.ts b/packages/detect-locale/src/utils/query-string.ts
new file mode 100644
index 00000000..3ae1dca1
--- /dev/null
+++ b/packages/detect-locale/src/utils/query-string.ts
@@ -0,0 +1,45 @@
+
+/**
+ * Simple query string parser.
+ *
+ * @param {String} query The query string that needs to be parsed.
+ * @returns {Object}
+ */
+export function parse(query) {
+  const parser = /([^=?#&]+)=?([^&]*)/g;
+  const result = {};
+  let part;
+
+  while (part = parser.exec(query)) {
+    const key = decode(part[1]);
+    const value = decode(part[2]);
+
+    //
+    // Prevent overriding of existing properties. This ensures that build-in
+    // methods like `toString` or __proto__ are not overriden by malicious
+    // querystrings.
+    //
+    // In the case if failed decoding, we want to omit the key/value pairs
+    // from the result.
+    //
+    if (key === null || value === null || key in result) continue;
+    result[key] = value;
+  }
+
+  return result;
+}
+
+function decode(input) {
+  try {
+    return decodeURIComponent(input.replace(/\+/g, ' '));
+  } catch (e) {
+    return null;
+  }
+}
+function encode(input) {
+  try {
+    return encodeURIComponent(input);
+  } catch (e) {
+    return null;
+  }
+}
diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts
index 3dbd8344..1c008bb8 100644
--- a/packages/macro/src/macroJsx.ts
+++ b/packages/macro/src/macroJsx.ts
@@ -16,9 +16,12 @@ const keepSpaceRe = /\s*(?:\r\n|\r|\n)+\s*/g
 const stripAroundTagsRe = /(?:([>}])(?:\r\n|\r|\n)+\s*|(?:\r\n|\r|\n)+\s*(?=[<{]))/g
 
 function maybeNodeValue(node) {
-  if (!node) return null
-  if (node.type === "StringLiteral") return node.value
-  if (node.type === "JSXAttribute") return maybeNodeValue(node.value)
+  if (!node)
+    return null
+  if (node.type === "StringLiteral")
+    return node.value
+  if (node.type === "JSXAttribute")
+    return maybeNodeValue(node.value)
   if (node.type === "JSXExpressionContainer")
     return maybeNodeValue(node.expression)
   if (node.type === "TemplateLiteral" && node.expressions.length === 0)
@@ -258,9 +261,13 @@ export default class MacroJSX {
     } else if (this.types.isJSXSpreadChild(node)) {
       // just do nothing
     } else if (this.types.isJSXText(node)) {
-      return this.tokenizeText(node.value)
+      // node.value has HTML entities converted to characters, but we need original
+      // HTML entities.
+      return this.tokenizeText((node as any).extra.raw)
     } else {
-      return this.tokenizeText(node.value)
+      // Same as above, but this time node.extra.raw also includes surrounding
+      // quotes. We need to strip them first.
+      return this.tokenizeText(node.extra.raw.replace(/(["'])(.*)\1/, "$2"))
     }
   }
 
diff --git a/scripts/build/bundles.js b/scripts/build/bundles.js
index 673afc7c..ae7cfee5 100644
--- a/scripts/build/bundles.js
+++ b/scripts/build/bundles.js
@@ -25,7 +25,10 @@ const bundles = [
     entry: "@lingui/react",
     externals: ["@lingui/core"]
   },
-
+  {
+    type: bundleTypes.UNIVERSAL,
+    entry: "@lingui/detect-locale",
+  },
   {
     type: bundleTypes.NODE,
     entry: "@lingui/babel-plugin-extract-messages"


Test patch:
diff --git a/packages/detect-locale/src/detectors/__snapshots__/fromUrl.test.ts.snap b/packages/detect-locale/src/detectors/__snapshots__/fromUrl.test.ts.snap
new file mode 100644
index 00000000..4a2978f3
--- /dev/null
+++ b/packages/detect-locale/src/detectors/__snapshots__/fromUrl.test.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fromUrl throws an error if parameter is not passed 1`] = `fromUrl parameter is required`;
diff --git a/packages/detect-locale/src/detectors/fromCookie.test.ts b/packages/detect-locale/src/detectors/fromCookie.test.ts
new file mode 100644
index 00000000..42df66f9
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromCookie.test.ts
@@ -0,0 +1,12 @@
+import { fromCookie } from "..";
+
+describe('fromCookie detector', () => {
+  it('should return a correct value from the cookie stored', () => {
+    Object.defineProperty(window.document, 'cookie', {
+      writable: true,
+      value: 'CONSENT=YES+ES.es+V11; SEARCH_SAMESITE=CgQI3JAB',
+    });
+    expect(fromCookie("CONSENT")).toEqual("YES+ES.es+V11")
+    expect(fromCookie("SEARCH_SAMESITE")).toEqual("CgQI3JAB")
+  });
+});
diff --git a/packages/detect-locale/src/detectors/fromHtmlTag.test.ts b/packages/detect-locale/src/detectors/fromHtmlTag.test.ts
new file mode 100644
index 00000000..731ab58c
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromHtmlTag.test.ts
@@ -0,0 +1,14 @@
+import { fromHtmlTag } from "..";
+import { JSDOM } from "jsdom"
+
+describe('htmlTag detector', () => {
+  it('should find a locale from a standard html5', () => {
+    const dom = new JSDOM(`<!DOCTYPE html><html lang="es"<p>Hello world</p></html>`);
+    expect(fromHtmlTag("lang", dom.window.document)).toEqual('es')
+  })
+
+  it('should find a locale from a xml lang attribute', () => {
+    const dom = new JSDOM(`<!DOCTYPE html><html xml:lang="en"<p>Hello world</p></html>`);
+    expect(fromHtmlTag("xml:lang", dom.window.document)).toEqual('en')
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromNavigator.test.ts b/packages/detect-locale/src/detectors/fromNavigator.test.ts
new file mode 100644
index 00000000..69912a42
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromNavigator.test.ts
@@ -0,0 +1,19 @@
+import { fromNavigator } from ".."
+
+describe("fromNavigator", () => {
+  it ("parses correctly navigator.language", () => {
+    const mock: Partial<Navigator> = {
+      language: "en_EN"
+    }
+    const locale = fromNavigator(mock)
+    expect(locale).toEqual("en_EN")
+  })
+
+  it ("on IE_11 navigator.language doesnt exist, goes to the fallback", () => {
+    const mock: Partial<Navigator & { userLanguage?: string }> = {
+      userLanguage: "es_ES"
+    }
+    const locale = fromNavigator(mock)
+    expect(locale).toEqual("es_ES")
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromPath.test.ts b/packages/detect-locale/src/detectors/fromPath.test.ts
new file mode 100644
index 00000000..9a2ab654
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromPath.test.ts
@@ -0,0 +1,11 @@
+import { fromPath } from "../";
+
+describe('fromPath detector', () => {
+  it('should find a locale on the first index', () => {
+    const mockedLocation: Partial<Location> ={
+      pathname: '/fr/some/route',
+    }
+
+    expect(fromPath(0, mockedLocation)).toEqual('fr')
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromStorage.test.ts b/packages/detect-locale/src/detectors/fromStorage.test.ts
new file mode 100644
index 00000000..e8c4f83f
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromStorage.test.ts
@@ -0,0 +1,17 @@
+import { fromStorage } from "..";
+
+describe('fromStorage detector', () => {
+  it('should return a correct value from the local storage stored', () => {
+    localStorage.setItem("lang", "es_ES")
+    expect(fromStorage("lang")).toEqual("es_ES")
+    localStorage.setItem("lang", null)
+    expect(fromStorage("lang")).toEqual("null")
+  });
+
+  it('should return a correct value from the session storage if opts passed', () => {
+    sessionStorage.setItem("lang", "en_EN")
+    expect(fromStorage("lang", { useSessionStorage: true })).toEqual("en_EN")
+    sessionStorage.setItem("lang", null)
+    expect(fromStorage("lang", { useSessionStorage: true })).toEqual("null")
+  });
+});
diff --git a/packages/detect-locale/src/detectors/fromSubdomain.test.ts b/packages/detect-locale/src/detectors/fromSubdomain.test.ts
new file mode 100644
index 00000000..552fdc67
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromSubdomain.test.ts
@@ -0,0 +1,11 @@
+import { fromSubdomain } from "../";
+
+describe('fromSubdomain detector', () => {
+  it('should find a locale on the first index', () => {
+    const mockedLocation: Partial<Location> ={
+      href: 'https://es.url.com/some_path',
+    }
+
+    expect(fromSubdomain(0, mockedLocation)).toEqual('es')
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/detectors/fromUrl.test.ts b/packages/detect-locale/src/detectors/fromUrl.test.ts
new file mode 100644
index 00000000..a3426ec3
--- /dev/null
+++ b/packages/detect-locale/src/detectors/fromUrl.test.ts
@@ -0,0 +1,35 @@
+import { fromUrl } from ".."
+
+describe("fromUrl", () => {
+  it ("parses correctly the param after an &", () => {
+    const mock: Partial<Location> = {
+      search: "?some_param=value&lang=en"
+    }
+    const locale = fromUrl("lang", mock)
+    expect(locale).toEqual("en")
+  })
+
+  it ("parses correctly the lang after an ?", () => {
+    const mock: Partial<Location> = {
+      search: "?lang=es"
+    }
+    const locale = fromUrl("lang", mock)
+    expect(locale).toEqual("es")
+  })
+
+  it ("parses correctly the lang with multiple params", () => {
+    const mock: Partial<Location> = {
+      search: "?lang=cs&some_other=other&otheR_params=232"
+    }
+    const locale = fromUrl("lang", mock)
+    expect(locale).toEqual("cs")
+  })
+
+  it ("throws an error if parameter is not passed", () => {
+    const mock: Partial<Location> = {
+      search: "?lang=cs&some_other=other&otheR_params=232"
+    }
+    expect(() => { fromUrl(undefined, mock) }).toThrowError()
+    expect(() => { fromUrl(undefined, mock) }).toThrowErrorMatchingSnapshot()
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/index.test.ts b/packages/detect-locale/src/index.test.ts
new file mode 100644
index 00000000..b43c688f
--- /dev/null
+++ b/packages/detect-locale/src/index.test.ts
@@ -0,0 +1,74 @@
+import { fromUrl, detect, fromNavigator, multipleDetect } from "./index"
+
+describe("detect", () => {
+  it ("accepts fromUrl method", () => {
+    const mock: Partial<Location> = {
+      search: "?some_param=value&lang=en"
+    }
+    expect(
+      detect(
+        fromUrl("lang", mock),
+      )
+    ).toEqual("en")
+  })
+
+  it ("accepts fromNavigator method", () => {
+    const mock: Partial<Navigator> = {
+      language: "en"
+    }
+    expect(
+      detect(
+        fromNavigator(mock),
+      )
+    ).toEqual("en")
+  })
+
+  it ("accepts multiple methods", () => {
+    const mock: Partial<Navigator> = {
+      language: "en"
+    }
+    const locationMock: Partial<Location> = {
+      search: "?some_param=value&lang=cz"
+    }
+    expect(
+      detect(
+        fromUrl("lang", locationMock),
+        fromNavigator(mock),
+      )
+    ).toEqual("cz")
+  })
+
+  it ("if not found any, returns the fallback", () => {
+    const mock: Partial<Navigator> = {
+      language: null
+    }
+    const locationMock: Partial<Location> = {
+      search: null
+    }
+    const fallback = () => "it"
+    expect(
+      detect(
+        fromUrl("lang", locationMock),
+        fromNavigator(mock),
+        fallback
+      )
+    ).toEqual("it")
+  })
+})
+
+describe("multipleDetect", () => {
+  it ("returns multiple locales by order of occurence", () => {
+    const mock: Partial<Navigator> = {
+      language: "en"
+    }
+    const locationMock: Partial<Location> = {
+      search: "?some_param=value&lang=cz"
+    }
+    expect(
+      multipleDetect(
+        fromUrl("lang", locationMock),
+        fromNavigator(mock),
+      )
+    ).toEqual(["cz", "en"])
+  })
+})
\ No newline at end of file
diff --git a/packages/detect-locale/src/utils/cookie-getter.test.ts b/packages/detect-locale/src/utils/cookie-getter.test.ts
new file mode 100644
index 00000000..07df9c57
--- /dev/null
+++ b/packages/detect-locale/src/utils/cookie-getter.test.ts
@@ -0,0 +1,14 @@
+// we import the polyfill on the test because on the lib is already imported on index.ts
+import "./globalThis.polyfill.js"
+import { getCookie } from "./cookie-getter"
+
+describe('getCookie', () => {
+  it('should return a correct value from the cookie stored', () => {
+    Object.defineProperty(window.document, 'cookie', {
+      writable: true,
+      value: 'CONSENT=YES+ES.es+V11; SEARCH_SAMESITE=CgQI3JAB',
+    });
+    expect(getCookie("CONSENT")).toEqual("YES+ES.es+V11")
+    expect(getCookie("SEARCH_SAMESITE")).toEqual("CgQI3JAB")
+  });
+});
diff --git a/packages/detect-locale/src/utils/query-string.test.ts b/packages/detect-locale/src/utils/query-string.test.ts
new file mode 100644
index 00000000..806471fa
--- /dev/null
+++ b/packages/detect-locale/src/utils/query-string.test.ts
@@ -0,0 +1,80 @@
+import { parse } from "./query-string"
+
+describe('query-string', function () {
+  describe('#parse', function () {
+    it('is exposed as method', function () {
+      expect(typeof parse).toBe("function");
+    });
+
+    it('will parse a querystring to an object', function () {
+      const obj: Record<string, unknown> = parse('foo=bar');
+
+      expect(typeof obj).toBe('object');
+      expect(obj.foo).toEqual('bar');
+    });
+
+    it('will also work if querystring is prefixed with ?', function () {
+      const obj: Record<string, unknown> = parse('?foo=bar&shizzle=mynizzle');
+
+      expect(typeof obj).toBe('object');
+      expect(obj.foo).toEqual('bar');
+      expect(obj.shizzle).toEqual('mynizzle');
+    });
+
+    it('will also work if querystring is prefixed with #', function () {
+      const obj: Record<string, unknown> = parse('#foo=bar&shizzle=mynizzle');
+
+      expect(typeof obj).toBe('object');
+      expect(obj.foo).toEqual('bar');
+      expect(obj.shizzle).toEqual('mynizzle');
+    });
+
+    it('does not overide prototypes', function () {
+      const obj: Record<string, unknown> = parse('?toString&__proto__=lol');
+
+      expect(typeof obj).toBe('object');
+      expect(typeof obj.toString).toBe('function');
+    });
+
+    it('works with querystring parameters without values', function () {
+      const obj: Record<string, unknown> = parse('?foo&bar=&shizzle=mynizzle');
+
+      expect(typeof obj).toBe('object');
+      expect(obj.foo).toEqual('');
+      expect(obj.bar).toEqual('');
+      expect(obj.shizzle).toEqual('mynizzle');
+    });
+
+    it('first value wins', function () {
+      const obj: Record<string, unknown> = parse('foo=1&foo=2');
+
+      expect(obj.foo).toEqual('1');
+    });
+
+    it('decodes plus signs', function () {
+      let obj: Record<string, unknown> = parse('foo+bar=baz+qux');
+
+      expect(typeof obj).toBe('object');
+      expect(obj['foo bar']).toEqual('baz qux');
+
+      obj = parse('foo+bar=baz%2Bqux');
+
+      expect(typeof obj).toBe('object');
+      expect(obj['foo bar']).toEqual('baz+qux');
+    });
+
+    it('does not throw on invalid input', function () {
+      const obj: Record<string, unknown> = parse('?%&');
+
+      expect(typeof obj).toBe('object');
+    });
+
+    it('does not include invalid output', function () {
+      const obj: Record<string, unknown> = parse('?%&');
+
+      expect(typeof obj).toBe('object');
+      expect(obj).toStrictEqual({});
+      expect(Object.keys(obj)).toHaveLength(0);
+    });
+  });
+});
\ No newline at end of file
diff --git a/packages/macro/test/jsx-trans.ts b/packages/macro/test/jsx-trans.ts
index b0a84f1d..78a82576 100644
--- a/packages/macro/test/jsx-trans.ts
+++ b/packages/macro/test/jsx-trans.ts
@@ -217,24 +217,22 @@ export default [
       `,
   },
   {
-    name: "Strip whitespace around arguments",
     input: `
         import { Trans } from "@lingui/macro";
         <Trans>
-          Strip whitespace around arguments: '
+          Strip whitespace around arguments: &quot;
           {name}
-          '
+          &quot;
         </Trans>
       `,
     expected: `
         import { Trans } from "@lingui/react";
-        <Trans id="Strip whitespace around arguments: '{name}'" values={{
+        <Trans id="Strip whitespace around arguments: &quot;{name}&quot;" values={{
           name: name
         }} />;
       `,
   },
   {
-    name: "Strip whitespace around tags but keep forced spaces",
     input: `
         import { Trans } from "@lingui/macro";
         <Trans>
@@ -251,7 +249,6 @@ export default [
       `,
   },
   {
-    name: "Keep forced newlines",
     input: `
         import { Trans } from "@lingui/macro";
         <Trans>
@@ -265,7 +262,6 @@ export default [
       `,
   },
   {
-    name: "Keep multiple forced newlines",
     input: `
         import { Trans } from "@lingui/macro";
         <Trans>
@@ -326,15 +322,4 @@ export default [
         <Trans id="Hello  World" />;
       `,
   },
-  {
-    name: "Use decoded html entities",
-    input: `
-        import { Trans } from "@lingui/macro";
-        <Trans>&amp;</Trans>
-      `,
-    expected: `
-        import { Trans } from "@lingui/react";
-        <Trans id="&" />;
-      `,
-  },
 ]