Repository: i18next/i18next-parser
Pull request #970 (2024-02-01 23:09:17)

Output the files in which a string is found in customValueTemplate
### Why am I submitting this PR

Thanks again for the great framework and maintenance @karellm -- we've now translated our [Wanderlog](https://wanderlog.com) trip planner mobile app to French with help from this!

As a part of the process, we realized that sometimes, translators can benefit with some extra context about the strings they're translating: e.g., it matters if a string like "Round trip" comes from a file called FlightSearchInput.tsx. The `customValueTemplates` have been really helpful with other annotations, so we thought it'd be helpful to extend it so that we can put:

```ts
{
  locales: [SupportedLocale.en],
  output: 'forTranslators.json',
  customValueTemplate: {
    message: '${defaultValue}',
    filePaths: '${filePaths}',
  },
}
```

### Changes

- Add an extra special-case for the customValueTemplate handling (similar to `defaultValue`) which will also substitute in the paths of all the files where we found a particular key
- Add tests to verify this behavior works properly with multiple files

**Note**: to make this easier to merge, I also invited you to have write access to our repository.

Example output when run on our codebase:

```json
{
  "Perfect for road trips and saving $$$ on gas! Get the best route auto-rearranged.": {
    "filePaths": [
      "/Users/peter/Code/itineraries/client/templates/LandingPage/LandingPageProFeatureTiles.tsx"
    ]
  },
  "See more": {
    "filePaths": [
      "/Users/peter/Code/itineraries/client/templates/LandingPage/LandingPageReview.tsx",
      "/Users/peter/Code/itineraries/client/templates/LandingPage/LandingPageTestimonial.tsx"
    ]
  }
}
```

### Does it fix an existing ticket?

No

### 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 8b7effa..12b1545 100644
--- a/README.md
+++ b/README.md
@@ -204,13 +204,33 @@ export default {
 
   customValueTemplate: null,
   // If you wish to customize the value output the value as an object, you can set your own format.
-  // ${defaultValue} is the default value you set in your translation function.
-  // Any other custom property will be automatically extracted.
+  //
+  // - ${defaultValue} is the default value you set in your translation function.
+  // - ${filePaths} will be expanded to an array that contains the absolute
+  //   file paths where the translations originated in, in case e.g., you need
+  //   to provide translators with context
+  //
+  // Any other custom property will be automatically extracted from the 2nd
+  // argument of your `t()` function or tOptions in <Trans tOptions={...} />
   //
   // Example:
-  // {
+  // For `t('my-key', {maxLength: 150, defaultValue: 'Hello'})` in
+  // /path/to/your/file.js,
+  //
+  // Using the following customValueTemplate:
+  //
+  // customValueTemplate: {
   //   message: "${defaultValue}",
-  //   description: "${maxLength}", // t('my-key', {maxLength: 150})
+  //   description: "${maxLength}",
+  //   paths: "${filePaths}",
+  // }
+  //
+  // Will result in the following item being extracted:
+  //
+  // "my-key": {
+  //   "message": "Hello",
+  //   "description": 150,
+  //   "paths": ["/path/to/your/file.js"]
   // }
 
   resetDefaultValueLocale: null,
diff --git a/src/helpers.js b/src/helpers.js
index 96e21db..7bbd58f 100644
--- a/src/helpers.js
+++ b/src/helpers.js
@@ -89,6 +89,8 @@ function dotPathToHash(entry, target = {}, options = {}) {
     entries.forEach((valueEntry) => {
       if (valueEntry[1] === '${defaultValue}') {
         inner[lastSegment][valueEntry[0]] = newValue
+      } else if (valueEntry[1] === '${filePaths}') {
+        inner[lastSegment][valueEntry[0]] = entry.filePaths
       } else {
         inner[lastSegment][valueEntry[0]] =
           entry[valueEntry[1].replace(/\${(\w+)}/, '$1')] || ''
diff --git a/src/transform.js b/src/transform.js
index 3cd7f7d..fb2d9c0 100644
--- a/src/transform.js
+++ b/src/transform.js
@@ -68,6 +68,10 @@ export default class i18nTransform extends Transform {
 
     this.i18next = i18next.createInstance()
     this.i18next.init(this.options.i18nextOptions)
+
+    // Track where individual keys are found, for use in customValueTemplate
+    // substitution into "${filePath}"
+    this.keyToFilePaths = {}
   }
 
   error(error) {
@@ -129,6 +133,12 @@ export default class i18nTransform extends Transform {
       key = key.replace(/\\\\/g, '\\')
       entry.key = key
       entry.keyWithNamespace = entry.namespace + this.options.keySeparator + key
+      // Add the filename so that we can use it in customValueTemplate
+      this.keyToFilePaths[key] = this.keyToFilePaths[key] || []
+      if (!this.keyToFilePaths[key].includes(file.path)) {
+        this.keyToFilePaths[key].push(file.path)
+      }
+      entry.filePaths = this.keyToFilePaths[key]
 
       this.addEntry(entry)
     }


Test patch:
diff --git a/test/parser.test.js b/test/parser.test.js
index 0a74c76..f06353c 100644
--- a/test/parser.test.js
+++ b/test/parser.test.js
@@ -1958,6 +1958,7 @@ describe('parser', () => {
           description: '${max}',
           namespace: '${namespace}',
           key: '${key}',
+          paths: '${filePaths}',
         },
       })
 
@@ -1968,6 +1969,11 @@ describe('parser', () => {
         path: 'file.js',
       })
 
+      const anotherFakeFile = new Vinyl({
+        contents: Buffer.from("t('test')"),
+        path: 'anotherFile.js',
+      })
+
       i18nextParser.on('data', (file) => {
         result = JSON.parse(file.contents)
       })
@@ -1979,18 +1985,21 @@ describe('parser', () => {
             description: '',
             namespace: 'translation',
             key: 'test',
+            paths: ['anotherFile.js', 'file.js'],
           },
           salt: {
             message: 'salty',
             description: '150',
             namespace: 'translation',
             key: 'salt',
+            paths: ['file.js'],
           },
         })
 
         done()
       })
 
+      i18nextParser.write(anotherFakeFile)
       i18nextParser.end(fakeFile)
     })