Repository: i18next/i18next-parser (js)

Issue:
Add a config to keep some dynamic keys
## 🚀 Feature Proposal

Add an option so that `i18next-parser` keeps some of the keys that are in the translation files but are not extracted from the code.

## Motivation

When using dynamic keys or dynamic contexts, there are multiple reasons for adding some keys that would be used at runtime while not easy to extract from the code statically. I'd like to be able to use `i18next-parser` to get all the other keys right, but trust me on the keys that I've added myself to the translation files.

As of v7, I only have the choice to either keep all existing translations (`keepRemoved` option) or delete all the ones that `i18next-parser` cannot extract from the code. I'd like to have another option and list patterns for which I want to keep the translations that exist.

## Example

Given the following code (using React):
```tsx
  const {t} = useTranslation()
  return <p>
    {t('pickACountryQuestion')}
    <select>
     {countries.map(countryId => <option value={countryId}>{t(`countryName.${countryId}`)}</option>)}
    </select>
  </p>
```

Adding this in the `i18next-parser.config.js`:
```js
module.exports = {
  keepRemovedPatterns: [
    /^translation:countryName\.\w+/,
  ]
}
```

I'd like to keep all the `countryName` sub tree containing something like:
```json
  "countryName": {

    "BDI": "Burundi",
    "BEL": "Belgium",
    "BEN": "Benin",

  }
```
while still removing other keys not used in my code.

Pull request description:
Add support for keepRemovedPatterns option - closes #693
### Why am I submitting this PR

To add a new option discussed in #693.

### Does it fix an existing ticket?

Yes #693 

### 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/CHANGELOG.md b/CHANGELOG.md
index ad22c36..c91c9e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+# Unreleased
+
+- Add support for patterns in the `keepRemoved` option
+
 # 8.1.0
 
 - Allow to disable plural handling #825 #463
diff --git a/README.md b/README.md
index 668b960..c31db25 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,9 @@ export default {
 
   keepRemoved: false,
   // Keep keys from the catalog that are no longer in code
+  // You may either specify a boolean to keep or discard all removed keys.
+  // You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept.
+  // The patterns are applied to the full key including the namespace, the parent keys and the separators.
 
   keySeparator: '.',
   // Key separator used in your translation keys
diff --git a/index.d.ts b/index.d.ts
index 9f5b96e..77ad44e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -83,7 +83,7 @@ export interface UserConfig {
   defaultNamespace?: string;
   defaultValue?: string | ((locale?: string, namespace?: string, key?: string) => string);
   indentation?: number;
-  keepRemoved?: boolean;
+  keepRemoved?: boolean | readonly RegEx[];
   keySeparator?: string | false;
   lexers?: {
     hbs?: (SupportedLexer | CustomLexer | LexerConfig)[];
diff --git a/src/helpers.js b/src/helpers.js
index 7045af9..0427f71 100644
--- a/src/helpers.js
+++ b/src/helpers.js
@@ -122,7 +122,10 @@ function mergeHashes(source, target, options = {}, resetValues = {}) {
   let oldCount = 0
   let resetCount = 0
 
-  const keepRemoved = options.keepRemoved || false
+  const keepRemoved = typeof options.keepRemoved === "boolean" && options.keepRemoved || false
+  const keepRemovedPatterns = typeof options.keepRemoved !== "boolean" && options.keepRemoved || []
+  const fullKeyPrefix = options.fullKeyPrefix || ''
+  const keySeparator = options.keySeparator || '.'
   const pluralSeparator = options.pluralSeparator || '_'
 
   for (const key in source) {
@@ -133,7 +136,7 @@ function mergeHashes(source, target, options = {}, resetValues = {}) {
       const nested = mergeHashes(
         source[key],
         target[key],
-        options,
+        { ...options, fullKeyPrefix: fullKeyPrefix + key + keySeparator },
         resetValues[key]
       )
       mergeCount += nested.mergeCount
@@ -185,7 +188,12 @@ function mergeHashes(source, target, options = {}, resetValues = {}) {
         target[key] = source[key]
         pullCount += 1
       } else {
-        if (keepRemoved) {
+        const keepKey =
+          keepRemoved ||
+          keepRemovedPatterns.some((pattern) =>
+            pattern.test(fullKeyPrefix + key)
+          )
+        if (keepKey) {
           target[key] = source[key]
         } else {
           old[key] = source[key]
diff --git a/src/transform.js b/src/transform.js
index b549a5d..1545764 100644
--- a/src/transform.js
+++ b/src/transform.js
@@ -236,6 +236,7 @@ export default class i18nTransform extends Transform {
           {
             ...this.options,
             resetAndFlag,
+            fullKeyPrefix: namespace + this.options.namespaceSeparator,
           },
           resetValues[namespace]
         )


Test patch:
diff --git a/test/helpers/mergeHashes.test.js b/test/helpers/mergeHashes.test.js
index 8af3475..7725a84 100644
--- a/test/helpers/mergeHashes.test.js
+++ b/test/helpers/mergeHashes.test.js
@@ -343,4 +343,46 @@ describe('mergeHashes helper function', () => {
     assert.strictEqual(res.resetCount, 1)
     done()
   })
+
+  it('keeps the unused keys if they match patterns in keepRemoved', (done) => {
+    const source = { key1: 'key1', key2: 'key2', dummy: 'unusedValue' }
+    const target = { key1: 'code1' }
+    const res = mergeHashes(source, target, {
+      keepRemoved: [/key.*/],
+      keySeparator: '.',
+    })
+    assert.deepEqual(res.new, {
+      key1: 'key1',
+      key2: 'key2',
+    })
+    assert.deepEqual(res.old, {
+      dummy: 'unusedValue',
+    })
+    done()
+  })
+
+  it('keeps unused nested keys if they match the patterns in keepRemoved', (done) => {
+    const source = {
+      nesting: { key1: 'key1', key2: 'key2', dummy: 'unusedValue' },
+      key2: 'rootKey2',
+    }
+    const target = { nesting: { key1: 'code1' } }
+    const res = mergeHashes(source, target, {
+      keepRemoved: [/nesting\.key.*/],
+      keySeparator: '.',
+    })
+    assert.deepEqual(res.new, {
+      nesting: {
+        key1: 'key1',
+        key2: 'key2',
+      },
+    })
+    assert.deepEqual(res.old, {
+      nesting: {
+        dummy: 'unusedValue',
+      },
+      key2: 'rootKey2',
+    })
+    done()
+  })
 })
diff --git a/test/parser.test.js b/test/parser.test.js
index 1d58540..8ec8ee7 100644
--- a/test/parser.test.js
+++ b/test/parser.test.js
@@ -1960,6 +1960,35 @@ describe('parser', () => {
       i18nextParser.end(fakeFile)
     })
 
+    it('supports keepRemoved option with patterns', (done) => {
+      let result, resultOld
+      const i18nextParser = new i18nTransform({
+        keepRemoved: [/test_merge:s.*/],
+        output: 'test/locales/$LOCALE/$NAMESPACE.json',
+      })
+      const fakeFile = new Vinyl({
+        contents: Buffer.from("t('test_merge:first')"),
+        path: 'file.js',
+      })
+
+      i18nextParser.on('data', (file) => {
+        if (file.relative.endsWith(path.normalize('en/test_merge.json'))) {
+          result = JSON.parse(file.contents)
+        } else if (
+          file.relative.endsWith(path.normalize('en/test_merge_old.json'))
+        ) {
+          resultOld = JSON.parse(file.contents)
+        }
+      })
+      i18nextParser.once('end', () => {
+        assert.deepEqual(result, { first: 'first', second: '' })
+        assert.deepEqual(resultOld, { third: 'third' })
+        done()
+      })
+
+      i18nextParser.end(fakeFile)
+    })
+
     describe('lexers', () => {
       it('supports custom lexers options', (done) => {
         let result