Repository: i18next/i18next-parser (js)

Issue:
Keys extracted with escaped characters starting in v8.4.0
## 🐛 Bug Report

Up to v8.3.0, the following code would result in the key being extracted as `"Don't click me"`:

```js
<Trans shouldUnescape>Don&apos;t click me</Trans>
```

Starting in v8.4.0, it gets extracted as `"Don&apos;t click me"`. The main reason this is an issue is that i18next will look for the key as "Don't click me", disregarding of whether `shouldUnescape` is `true` or `false` (I reported that as a separate issue [here](https://github.com/i18next/react-i18next/issues/1662)).

This basically means that all the keys extracted by i18next-parser which contained escaped characters will be ignored by i18next.

## Your Environment

- *runtime version*: node v18
- *i18next version*: 23.4.4
- *react-i18next*: 13.1.2
- *os*: MacOs


Pull request description:
Fix unescape logic, expression props, and ICU format for Trans component
### Why am I submitting this PR
After #871, it seems I introduced a regression, though it did help find a bug.

~~It seems that react-i18next does indeed default the key to the `defaults` prop (I added a comment to prevent anyone from making the same mistake again). I've reverted the changes from #871 to match react-i18next's behavior.~~ After reporting this on react-i18next's bug tracker (i18next/react-i18next#1663), it looks like this was unintended. I fixed that, so no need to revert #871.

Otherwise, it seems we had a bug where we were unescaping values in reverse (i.e. unescaping when it wasn't needed, and not unescaping when it was needed). I corrected this, and also made it so that we always unescape the key, since this matches react-i18next's behavior as well.

### Does it fix an existing ticket?
Closes #886.

### Checklist
- [x] only relevant code is changed (make a diff before you submit the PR)
- [x] tests are included and pass: `yarn test` (see [details here](https://github.com/i18next/i18next-parser/blob/master/docs/development.md#tests))

Patch:
diff --git a/src/lexers/jsx-lexer.js b/src/lexers/jsx-lexer.js
index 6c1e02f..da9a380 100644
--- a/src/lexers/jsx-lexer.js
+++ b/src/lexers/jsx-lexer.js
@@ -125,9 +125,15 @@ export default class JsxLexer extends JavascriptLexer {
             ) {
               entry[property.name.text] = false
             } else {
-              entry[
-                property.name.text
-              ] = `{${property.initializer.expression.text}}`
+              entry[property.name.text] = `{${
+                property.initializer.expression.text ||
+                this.cleanMultiLineCode(
+                  sourceText.slice(
+                    property.initializer.expression.pos,
+                    property.initializer.expression.end
+                  )
+                )
+              }}`
             }
           } else {
             entry[property.name.text] = property.initializer.text
@@ -138,7 +144,10 @@ export default class JsxLexer extends JavascriptLexer {
       const nodeAsString = this.nodeToString.call(this, node, sourceText)
       const defaultsProp = getPropValue(tagNode, 'defaults')
       let defaultValue = defaultsProp || nodeAsString
-      if (entry.shouldUnescape === true) {
+
+      // If `shouldUnescape` is not true, it means the value cannot contain HTML entities,
+      // so we need to unescape these entities now so that they can be properly rendered later
+      if (entry.shouldUnescape !== true) {
         defaultValue = unescape(defaultValue)
       }
 
@@ -146,7 +155,9 @@ export default class JsxLexer extends JavascriptLexer {
         entry.defaultValue = defaultValue
 
         if (!entry.key) {
-          entry.key = nodeAsString
+          // If there's no key, default to the stringified unescaped node, then to the default value:
+          // https://github.com/i18next/react-i18next/blob/95f9c6a7b602a7b1fd33c1ded6dcfc23a52b853b/src/TransWithoutContext.js#L337
+          entry.key = unescape(nodeAsString) || entry.defaultValue
         }
       }
 
@@ -192,15 +203,19 @@ export default class JsxLexer extends JavascriptLexer {
     return elemsToString(children)
   }
 
+  cleanMultiLineCode(text) {
+    return text
+      .replace(/(^(\n|\r)\s*)|((\n|\r)\s*$)/g, '')
+      .replace(/(\n|\r)\s*/g, ' ')
+  }
+
   parseChildren(children = [], sourceText) {
     return children
       .map((child) => {
         if (child.kind === ts.SyntaxKind.JsxText) {
           return {
             type: 'text',
-            content: child.text
-              .replace(/(^(\n|\r)\s*)|((\n|\r)\s*$)/g, '')
-              .replace(/(\n|\r)\s*/g, ' '),
+            content: this.cleanMultiLineCode(child.text),
           }
         } else if (
           child.kind === ts.SyntaxKind.JsxElement ||


Test patch:
diff --git a/test/lexers/jsx-lexer.test.js b/test/lexers/jsx-lexer.test.js
index 6028c2a..e1745d9 100644
--- a/test/lexers/jsx-lexer.test.js
+++ b/test/lexers/jsx-lexer.test.js
@@ -132,6 +132,29 @@ describe('JsxLexer', () => {
       done()
     })
 
+    it('extracts keys from Trans elements without an i18nKey, with defaults, and without children', (done) => {
+      const Lexer = new JsxLexer()
+      // Based on https://react.i18next.com/latest/trans-component#alternative-usage-components-array
+      const content = `
+<Trans
+  defaults="hello <0>{{what}}</0>"
+  values={{
+    what: "world"
+  }}
+  components={[<strong />]}
+/>
+`.trim()
+      assert.deepEqual(Lexer.extract(content), [
+        {
+          key: 'hello <0>{{what}}</0>',
+          defaultValue: 'hello <0>{{what}}</0>',
+          components: '{[<strong />]}',
+          values: '{{ what: "world" }}',
+        },
+      ])
+      done()
+    })
+
     it('extracts keys from Trans elements and ignores values of expressions and spaces', (done) => {
       const Lexer = new JsxLexer()
       const content = '<Trans count={count}>{{ key: property }}</Trans>'
@@ -452,12 +475,28 @@ describe('JsxLexer', () => {
         done()
       })
 
+      it('does not unescape i18nKey', (done) => {
+        const Lexer = new JsxLexer()
+        const content =
+          '<Trans i18nKey="I&apos;m testing">I&apos;m Cielquan</Trans>'
+        assert.equal(Lexer.extract(content)[0].key, 'I&apos;m testing')
+        done()
+      })
+
+      it('unescapes key when i18nKey is not provided', (done) => {
+        const Lexer = new JsxLexer()
+        const content = '<Trans>I&apos;m Cielquan</Trans>'
+        assert.equal(Lexer.extract(content)[0].key, "I'm Cielquan")
+        done()
+      })
+
       it('supports the shouldUnescape options', (done) => {
         const Lexer = new JsxLexer()
-        const content = '<Trans shouldUnescape>I&apros;m Cielquan</Trans>'
+        const content = '<Trans shouldUnescape>I&apos;m Cielquan</Trans>'
+        assert.equal(Lexer.extract(content)[0].key, "I'm Cielquan")
         assert.equal(
           Lexer.extract(content)[0].defaultValue,
-          'I&apros;m Cielquan'
+          'I&apos;m Cielquan'
         )
         done()
       })