Repository: i18next/i18next-parser
Pull request #863 (2023-07-20 00:15:29)

Allow ignoring typecheck-helper functions around Trans tag variables
### Why am I submitting this PR

Thanks again for maintaining this, @karellm!

This PR helps with extraction for React + Typescript + i18next projects that use a helper function to help with typechecking.

By default, for React with Typescript, components don't allow passing in objects as children. e.g., the below wouldn't typecheck:

```tsx
const SomeComponent = (props) => (
  <Trans>Hello <b>{{ name: props.name }}</b></Trans>
);
```

i18next added [a type option, `allowObjectInHTMLChildren`](https://www.i18next.com/overview/typescript#custom-type-options) that helps with HTML elements. However, this still doesn't work for non-HTML components: e.g., the following would still not pass the typechecker.

```tsx
const SomeComponent = (props) => (
  <Trans>Hello <Link to="/profile">{{ name: props.name }}</Link></Trans>
);
```

As a result, we (and some other teams we know) use either of the below methods:


```tsx
// Option 1: already supported
const SomeComponent = (props) => (
  <Trans>Hello <Link to="/profile">{{ name: props.name } as any}</Link></Trans>
);

// Option 2
const SomeComponent = (props) => (
  <Trans>Hello <Link to="/profile">{{ name: props.name } as unknown as string}</Link></Trans>
);

// Option 3: slightly nicer because we can use a lint rule
// to make sure that castAsString() only appears in <Trans>
const SomeComponent = (props) => (
  <Trans>Hello <Link to="/profile">{castAsString({ name: props.name })}</Link></Trans>
);

function castAsString(obj: Record<string, unknown>): string {
  return obj as unknown as string;
}
```

However, right now, i18next-parser would not pick up `{ name: props.name }` since it's in a function call or multiple levels of type-casts.

### Changes

- Allow passing in an option to the JSX Lexer, `transIdentityFunctionsToIgnore`, that specifies identity functions to "unwrap" before parsing a Trans object for strings
- Allow multiple levels of type-casting (if we're not using an `as any` cast) to be unwrapped

### Does it fix an existing ticket?

No, but is somewhat related to #603 

### 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))
- [X] documentation is changed or added



Patch:
diff --git a/README.md b/README.md
index c31db25..d763b26 100644
--- a/README.md
+++ b/README.md
@@ -285,6 +285,37 @@ If your JSX files have `.js` extension (e.g. create-react-app projects) you shou
 }
 ```
 
+##### Supporting helper functions in Trans tags
+
+If you're working with i18next in Typescript, you might be using a helper function to make sure that objects in <Trans /> components pass the typechecker: e.g.,
+
+```tsx
+const SomeComponent = (props) => (
+  <Trans>
+    Visit
+    <Link to='/user/john'>{castAsString({ name: props.name })}'s profile</Link>
+    {/* Equivalent to, but resolves typechecker errors with */}
+    <Link to='/user/john'>{{ name: props.name }}'s profile</Link>
+  </Trans>
+)
+
+function castAsString(record: Record<string, unknown>): string {
+  return record as unknown as string
+}
+```
+
+In order for the parser to extract variables properly, you can add a list of
+such functions to the lexer options:
+
+```
+{
+  js: [{
+    lexer: 'JsxLexer',
+    transIdentityFunctionsToIgnore: ['castAsString']
+  }],
+}
+```
+
 #### Ts(x)
 
 Typescript is supported via Javascript and Jsx lexers. If you are using Javascript syntax (e.g. with React), follow the steps in Jsx section, otherwise Javascript section.
diff --git a/index.d.ts b/index.d.ts
index 77ad44e..5898157 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -60,6 +60,17 @@ export interface JsxWithTypesLexerConfig {
   transKeepBasicHtmlNodesFor?: string[];
   parseGenerics: true;
   typeMap: Record<string, unknown>;
+  /**
+   * Identity functions within trans that should be parsed for their
+   * first arguments. Used for making typecheckers happy in a safe way: e.g., if in your code, you use:
+   *
+   * ```
+   * <Trans>Hello {castToString({ name })}</Trans>
+   * ```
+   *
+   * you'd want to pass in `transIdentityFunctionsToIgnore: ['castToString']`
+   */
+  transIdentityFunctionsToIgnore?: string[];
 }
 
 export interface VueLexerConfig {
diff --git a/src/lexers/jsx-lexer.js b/src/lexers/jsx-lexer.js
index e7c2e23..8b8941c 100644
--- a/src/lexers/jsx-lexer.js
+++ b/src/lexers/jsx-lexer.js
@@ -16,6 +16,8 @@ export default class JsxLexer extends JavascriptLexer {
       'p',
     ]
     this.omitAttributes = [this.attr, 'ns', 'defaults']
+    this.transIdentityFunctionsToIgnore =
+      options.transIdentityFunctionsToIgnore || []
   }
 
   extract(content, filename = '__default.jsx') {
@@ -208,14 +210,9 @@ export default class JsxLexer extends JavascriptLexer {
           const element = child.openingElement || child
           const name = element.tagName.escapedText
           const isBasic = !element.attributes.properties.length
-          const hasDynamicChildren = element.attributes.properties.find(
-            (prop) => prop.name.escapedText === 'i18nIsDynamicList'
-          )
           return {
             type: 'tag',
-            children: hasDynamicChildren
-              ? []
-              : this.parseChildren(child.children, sourceText),
+            children: this.parseChildren(child.children, sourceText),
             name,
             isBasic,
             selfClosing: child.kind === ts.SyntaxKind.JsxSelfClosingElement,
@@ -230,10 +227,35 @@ export default class JsxLexer extends JavascriptLexer {
           }
 
           // simplify trivial expressions, like TypeScript typecasts
-          if (child.expression.kind === ts.SyntaxKind.AsExpression) {
+          while (child.expression.kind === ts.SyntaxKind.AsExpression) {
             child = child.expression
           }
 
+          // Sometimes, we might want to wrap ObjectExpressions in a function
+          // for typechecker compatibility: e.g.,
+          //
+          // Instead of
+          // `<Trans>Hello, <Link to="/">{{ name }}</Link></Trans>`
+          // we might want:
+          // `<Trans>Hello, <Link to="/">{castToString({ name })}</Link></Trans>`
+          //
+          // because that way, we can have {castToString(...)} be typed
+          // in a a way to return a string, which would be type-compatible
+          // with `children?: React.ReactNode`
+          //
+          // In these cases, we want to look at the object expressions within
+          // the function call to extract the variables
+          if (
+            child.expression.kind === ts.SyntaxKind.CallExpression &&
+            child.expression.expression.kind === ts.SyntaxKind.Identifier &&
+            this.transIdentityFunctionsToIgnore.includes(
+              child.expression.expression.escapedText
+            ) &&
+            child.expression.arguments.length >= 1
+          ) {
+            child = { expression: child.expression.arguments[0] }
+          }
+
           if (child.expression.kind === ts.SyntaxKind.StringLiteral) {
             return {
               type: 'text',
@@ -247,13 +269,11 @@ export default class JsxLexer extends JavascriptLexer {
             child.expression.kind === ts.SyntaxKind.ObjectLiteralExpression
           ) {
             // i18next-react only accepts two props, any random single prop, and a format prop
+            // for our purposes, format prop is always ignored
 
-            const nonFormatProperties = child.expression.properties.filter(
+            let nonFormatProperties = child.expression.properties.filter(
               (prop) => prop.name.text !== 'format'
             )
-            const formatProperty = child.expression.properties.find(
-              (prop) => prop.name.text === 'format'
-            )
 
             // more than one property throw a warning in i18next-react, but still works as a key
             if (nonFormatProperties.length > 1) {
@@ -268,15 +288,9 @@ export default class JsxLexer extends JavascriptLexer {
               }
             }
 
-            // This matches the behaviour of the Trans component in i18next as of v13.0.2:
-            // https://github.com/i18next/react-i18next/blob/0a4681e428c888fe986bcc0109eb19eab6ff2eb3/src/TransWithoutContext.js#L88
-            const value = formatProperty
-              ? `${nonFormatProperties[0].name.text}, ${formatProperty.initializer.text}`
-              : nonFormatProperties[0].name.text
-
             return {
               type: 'js',
-              content: `{{${value}}}`,
+              content: `{{${nonFormatProperties[0].name.text}}}`,
             }
           }
 


Test patch:
diff --git a/test/lexers/jsx-lexer.test.js b/test/lexers/jsx-lexer.test.js
index ec44134..b2cfd74 100644
--- a/test/lexers/jsx-lexer.test.js
+++ b/test/lexers/jsx-lexer.test.js
@@ -132,20 +132,6 @@ describe('JsxLexer', () => {
       done()
     })
 
-    it('extracts formatted interpolations correctly', (done) => {
-      const Lexer = new JsxLexer()
-      const content =
-        '<Trans count={count}>{{ key: property, format: "number" }}</Trans>'
-      assert.deepEqual(Lexer.extract(content), [
-        {
-          key: '{{key, number}}',
-          defaultValue: '{{key, number}}',
-          count: '{count}',
-        },
-      ])
-      done()
-    })
-
     it('extracts keys from user-defined components', (done) => {
       const Lexer = new JsxLexer({
         componentFunctions: ['Translate', 'FooBar'],
@@ -232,17 +218,6 @@ describe('JsxLexer', () => {
       done()
     })
 
-    it('skips dynamic children', (done) => {
-      const Lexer = new JsxLexer()
-      const content =
-        '<Trans>My dogs are named: <ul i18nIsDynamicList>{["rupert", "max"].map(dog => (<li>{dog}</li>))}</ul></Trans>'
-      assert.equal(
-        Lexer.extract(content)[0].defaultValue,
-        'My dogs are named: <1></1>'
-      )
-      done()
-    })
-
     it('erases comment expressions', (done) => {
       const Lexer = new JsxLexer()
       const content = '<Trans>{/* some comment */}Some Content</Trans>'
@@ -452,6 +427,35 @@ describe('JsxLexer', () => {
         )
         done()
       })
+
+      it('supports multi-step casts', (done) => {
+        const Lexer = new JsxLexer()
+        const content =
+          '<Trans>Hi, {{ name: "John" } as unknown as string}</Trans>'
+        assert.equal(Lexer.extract(content)[0].defaultValue, 'Hi, {{name}}')
+        done()
+      })
+
+      it('supports variables in identity functions', (done) => {
+        const Lexer = new JsxLexer({
+          transIdentityFunctionsToIgnore: ['funcCall'],
+        })
+        const content = '<Trans>Hi, {funcCall({ name: "John" })}</Trans>'
+        assert.equal(Lexer.extract(content)[0].defaultValue, 'Hi, {{name}}')
+        done()
+      })
+
+      it('leaves non-identify functions alone', (done) => {
+        const Lexer = new JsxLexer({
+          transIdentityFunctionsToIgnore: ['funcCall'],
+        })
+        const content = '<Trans>Hi, {anotherFuncCall({ name: "John" })}</Trans>'
+        assert.equal(
+          Lexer.extract(content)[0].defaultValue,
+          'Hi, {anotherFuncCall({ name: "John" })}'
+        )
+        done()
+      })
     })
   })
 })